PHP - Come estrarre il contenuto XML da un file XML.P7M (CAdES, Fattura PA)

php-cgi.exe - The FastCGI process exited unexpectedly error and how to fix it

Qualche giorno fa mi è stato chiesto di preparare un paio di pagine PHP per estrarre il contenuto di una serie di file XML relativi a fatture elettroniche per la Pubblica Amministrazione, realizzati cioè seguendo il formato definito dall'Agenzia delle Entrate e noto con il nome di Fattura PA. Si trattava di un lavoro semplice, nel corso del quale mi sono trovato a dover risolvere molto rapidamente - per ragioni legate ai tempi di consegna - due problemi non banali: estrarre il contenuto XML da una serie di file .xml.p7m firmati digitalmente ed eliminare dal contenuto i caratteri non UTF8 nel contenuto XML.

Dovendo concludere lo sviluppo rapidamente ho deciso di risolvere entrambe le problematiche allineandomi a uno dei più famosi luoghi comuni che, non senza un fondo di verità, accompagnano da sempre le caratteristiche del linguaggio PHP: il fatto che si tratti di un "double clawed hammer", ovvero di un martello a due penne (e nessuna testa). In questo articolo ci occuperemo del primo argomento, rimandando il secondo a tempi migliori (UPDATE: alla fine l'ho scritto! Se vi interessa leggerlo, fate click qui).

PHP - Come estrarre il contenuto XML da un file XML.P7M (CAdES, Fattura PA)
Immagine di un lavoro originale realizzato da Ian Baker. Altre foto di questa meravigliosa opera sono disponibili sulla sua pagina Flickr: https://www.flickr.com/photos/raindrift/sets/72157629492908038

Per quanto riguarda l'estrazione XML dal P7M ho approfittato del fatto che tutte le fatture erano state firmate digitalmente utilizzando il formato CAdES, il quale - come forse già sapete - prevede l'aggiunta al file originale di un header PKCS#7 in testa e di una signature info in coda, lasciando il contenuto in mezzo intalterato. Questo consente di rimuovere entrambi, a patto di riuscire a localizzare l'esatta posizione del contenuto che si desidera preservare. Nel caso di un file XML questo è fortunatamente piuttosto semplice. E' importante sottolineare come procedere in questo modo non ci dà nessuna garanzia sulla bontà della firma digitale stessa, che viene scartata senza alcuna verifica. Nel caso specifico, trattandosi di documenti già verificati alla fonte e archiviati digitalmente a monte del mio visualizzatore, io ho potuto farne a meno senza problemi. Al tempo stesso, suggerisco di prestare la massima attenzione al proprio ambito di uso: se avete bisogno di verificare l'autore del file è senz'altro opportuno utilizzare un metodo diverso che tenga in considerazione la firma del mittente.

Tutto ciò premesso, ecco il codice che ho utilizzato:

Non c'è molto da spiegare: ho semplicemente utilizzato una combinazione di alcune funzioni di string lookup / string manipulation fornite da PHP - strpos, substr, preg_match_all - per localizzare i tag XML all'inizio e alla fine del documento, eliminando tutto il contenuto che viene prima o dopo di loro.

Lo scenario applicativo più comune di una funzione di questo tipo è in conseguenza della ricezione di una REQUEST POST in formato multipart contenente un file XML.P7M come parametro:

... e così via.

Si tratta di un metodo estremamente inappropriato per elaborare un file di questo tipo, ma non avendo la possibilità di controllare la firma è l'unico modo che mi è venuto in mente per risolvere rapidamente il problema.

Utilizzatelo tenendo presente le raccomandazioni di cui sopra, e... felice sviluppo!

Riferimenti utili

About Ryan

IT Project Manager, Web Interface Architect e Lead Developer di numerosi siti e servizi web ad alto traffico in Italia e in Europa. Dal 2010 si occupa anche della progettazione di App e giochi per dispositivi Android, iOS e Mobile Phone per conto di numerose società italiane. Microsoft MVP for Development Technologies dal 2018.

View all posts by Ryan

25 Comments on “PHP - Come estrarre il contenuto XML da un file XML.P7M (CAdES, Fattura PA)”

  1. Pingback: PHP - Eliminare i caratteri non validi da un file o stringa XML in UTF-8
  2. Grazie per la funzione che utilizzo per leggere le fatture elettroniche firmate.
    Rimane un problema, nella prima fase di pulizia della stringa, viene tolto anche il tag di chiusura della fattura “”.
    Come posso fare per non eliminarlo?
    Potrei aggiungerlo successivamente, ma il tag non è sempre uguale.
    Saluti

    1. Non sono sicuro di aver capito il tuo problema, a me non elimina nessun tag di chiusura fattura (non ho capito qual è, puoi riscriverlo?)

      1. Attenzione lastMatch[1] avrà il numero di caratteri fino all’inizio dell’ultimo TAG XML quindi questa funzione taglia l’ultimo tag usata in questo modo.

        va corretto in questo modo:

        return substr($string, 0, $lastMatch[1]+strlen($lastMatch[0]));

        1. Grazie al tuo commento ho capito cosa intendeva dire il precedente lettore e ho potuto riscontrare (e correggere) l’errore nell’articolo: grazie mille per il contributo!

  3. non capisco come effettuare un ciclo “if” nel caso in cui un nostro cliente passasse un file xml senza firma. Attualmente riesco a elaborare un file xml e visualizzare correttamente una fattura, ma nel caso di un p7m ho qualche problema.

    1. Se osservi la prima riga della funzione stripP7MData() presente nell’articolo, noterai che la variabile $string viene riempita con tutti i caratteri presenti all’interno del file che precedono l’inizio del tag XML. Poiché in un XML senza firma quella variabile sarebbe vuota, il ciclo IF potrebbe essere il seguente:

      $string = substr($string, strpos($string, ‘<?xml ‘));
      if ($string) {
      // file XML con firma
      }
      else {
      // file XML senza firma
      }

    1. Ciao,

      grazie a te per il commento. Lo standard XAdES non presenta particolari problemi, in quanto il blocco firma si trova all’interno di un elemento XML (solitamente denominato “signature”) che può essere individuato e rimosso facilmente con qualsiasi XML parser. Dipende ovviamente da dove devi “importare” il documento.

      Se hai bisogno di un software che elimini le firme digitali dai file XML, ad esempio per una importazione all’interno di un gestionale, ti consigliamo di dare un’occhiata al nostro P7M Convert.

  4. Mi domando come sia possibile che venga scritto un articolo che spieghi come risolvere un problema, che non funzioni affatto.
    Mi domando su quali file ha fatto i test, visto che quelli che ho io non vengono estratti bene

    1. Come puoi leggere dai commenti di cui sopra numerosi utenti sono riusciti a utilizzare il nostro codice senza grossi problemi: considera inoltre che su questa base abbiamo realizzato un software (P7M Convert) che è attualmente utilizzato da decine di commercialisti in tutta Italia.

      Se ci spieghi qual è il tuo problema magari possiamo aiutarti: tanto per cominciare, sei sicuro al 100% che i file che provi a convertire siano fatture XML.P7M in formato CAdES?

  5. Certamente sono file xml.p7m. Il formato credo sia CAdES, dal momento che sul sito dell’agenzia dell’entrate dicono che questi file siano in formato CAdES. Nel caso dove si verifica che tipo di formato è?
    Si trattano di fatture passive, quindi presumo che la struttura sia sempre la stessa.
    Ad ogni modo credo ci sia qualche problema con l’espressione regolare, perché non mi estrae bene l’xml.
    Ho risolto così:
    $string = substr($string, strpos($string, ‘<?xml ‘));
    $res = substr($string, 0, strpos($string, ‘FatturaElettronica>’)+strlen(“FatturaElettronica>”));
    return $this->sanitizeXML($res);

    Quindi prendo tutto ciò che sta tra ‘<?xml’ e ‘FatturaElettronica>’.
    Infine chiamo sanitizeXML che è la funzione che hai definito qui: https://www.ryadel.com/php-eliminare-caratteri-non-validi-file-stringa-xml-utf8-utf-8/

    Così mi funziona perfettamente.

    Non volevo sembrare scortese comunque

    1. Magari stai usando una versione di PHP molto diversa da quella che abbiamo utilizzato noi e con un supporto preg che funziona in modo diverso. Approfondiremo la problematica nel weekend e, nel caso, aggiorneremo l’articolo. Se ti va, facci sapere (anche in privato se vuoi, tramite il form Contatti) le caratteristiche del tuo environment così da velocizzare l’analisi. Sempre se ti va, puoi provare a convertire una delle tue fatture tramite uno dei numerosi strumenti online che si basano sul nostro codice, come ad esempio questo:

      https://www.studiobarberi.it/fattura-elettronica/

      Nessuna scortesia, ci mancherebbe, ci tenevamo solo a precisare che il metodo funziona ed è stato provato e implementato più volte, anche se ovviamente non è detto che copra tutti i casi e che sia compatibile con tutti gli environment: siamo qui apposta.

      Ciao e grazie per l’interessamento al nostro post!

  6. Scaricando il file XML con il servizio API di Aruba la funzione stripP7MData() elimina la testa e la coda della firma, ma non i caratteri che si trovano all’interno dell’XML.
    Ad esempio, ho una fattura con righe come
    <RegimeFiscale>EOT‚ETXèRF01</RegimeFiscale>
    oppure
    <IEOT‚STX(mponibileImporto>100.00</ImponibileImporto>
    che rappresentano un problema.

    1. Ciao Gianni,

      questo articolo spiega come eliminare la firma, per gestire i caratteri non validi devi leggere quest’altro (come spiegato nell’articolo stesso):

      https://www.ryadel.com/php-eliminare-caratteri-non-validi-file-stringa-xml-utf8-utf-8/

      Tieni anche presente che se l’XML è “malformed” all’origine le funzioni di conversione non possono fare miracoli, anche perché entrare nel merito di tag errati e/o del loro contenuto è ovviamente una cosa da evitare (c’è il rischio di alterare importi o dati fondamentali).

      In bocca al lupo, facci sapere.

      1. Il supporto di aruba afferma che tutti i caratteri “spuri” dentro il codice XML fanno parte della firma (anche quelli che rendono malforme l’XML) e mi invitano ad utilizzare delle librerie PHP per estrarre il file originale dal wrapper p7m.
        Rimangono tuttavia molto vaghi su cosa esattamente usare…

        1. Perdonami se insisto, ma la firma (qualsiasi firma) occupa uno spazio ben preciso all’interno dell’XML e non ha niente a che spartire con i caratteri all’interno di eventuali altri tag, come nel caso del regime fiscale (et. al.). Puoi verificare tu stesso la cosa prendendo una qualsiasi fattura XML non firmata, firmandola e confrontando i due file con Winmerge.

          Tutto ciò premesso:

          • Per estrarre il file originale dal wrapper P7M va utilizzata la funzione che trovi in questo articolo.

          • Per eliminare i caratteri non validi da un XML va utilizzata la funzione che trovi in quest’altro articolo: https://www.ryadel.com/php-eliminare-caratteri-non-validi-file-stringa-xml-utf8-utf-8/

  7. Il programma sembra funzionare, e nella maggior parte dei casi, come osservato da molti, funziona.

    Ma il formato P7M non ha semplicemente una testata e un footer: il testo è diviso in segmenti di (spero sia fisso) 1000 byte e fra i segmenti c’è un separatore binario. Ecco perché rimuovendo semplicemente testa e coda, il risultato a volte funziona (se i separatori finiscono nel testo, e la ragione sociale diventa “CANISTR?&” ò!ACCI OIL SNC”, ma l’XML rimane valido) e a volte non funziona (se il separatore finisce in un tag XML, per cui si crea un tag “DatiCedentePre&%$!étatore” che non esiste).

    Nella maggior parte dei casi questo si risolve con un giro di Sanitize, perché i caratteri extra sono binari fuori UTF-8 e quindi vengono eliminati.

    Occasionalmente però il combinato disposto di carattere binario e del carattere successivo che si combina correttamente fa sì che Sanitize elimini troppo o troppo poco. Di nuovo, “CANISTRèCCI OIL” viene accettato, mentre “Fatturalettronica” no.

    Il codice corretto per ricombinare i vari pezzi, saltando i separatori, è qualcosa come

    $fatt =
    $good = ”;
    for (;;) {
    if (strlen($fatt) < 1000) {
    $good .= $fatt;
    break;
    }
    $good .= substr($fatt, 0, 1000);
    $fatt = substr($fatt, 1004);
    }

    (Inoltre, esiste una probabilità molto bassa ma non nulla (dell’ordine di uno su sedici milioni; purtroppo molto di più se qualche pazzo inserirà (X)HTML nei testi del certificato) che nel blocco di firma compaiano per caso i caratteri “</>”. Quando ciò succede, la seconda regex di pulizia fallisce. Per ridurre il rischio, visto che il formato del file interno è in genere noto, magari è meglio usare l’ultimo tag – e, per sicurezza, ignorarne il possibile namespace (“#TagDiChiusura>#”) per trovare o ).

    1. Ciao,

      grazie per il tuo contributo: in realtà l’articolo spiega chiaramente che il codice riportato risolve soltanto il primo dei due problemi da affrontare per la corretta lettura/conversione dei file P7M, ovvero “estrarre il contenuto XML eliminando la parte relativa alla firma” (header e footer). Per risolvere l’altro problema, ovvero “eliminare dal contenuto i caratteri XML-invalid”, c’è un rimando esplicito a un secondo articolo, che di fatto propone un “sanitize” pensato proprio per gestire il problema dei separatori (\u0004+\u201A+ etc.) che il software di firma appone al termine dei singoli chunks.

      Quando scrissi l’articolo mi sembrava chiaro, ma forse è meglio spiegare meglio che le operazioni da effettuare per ottenere la conversione completa sono effettivamente due (al netto dell’ulteriore rilievo sul caso su 16 milioni che giustamente fai).

      Fammi sapere se ti torna, così provvedo a modificare l’articolo anche a beneficio degli altri utenti.

      1. Ciao, scusa il ritardo; devo essere riuscito a perdermi la notifica via mail.

        Nel frattempo mi sono documentato sul formato di quei file e ho fatto esperimenti su qualche migliaio di XML :-)

        La maggioranza sono DER tranquilli (si aprono con OpenSSL a linea di comando):

        openssl smime -verify -in file.xml.p7m -inform der -noverify  -out file.xml
        

        Una minoranza sono in un formato ibrido per il quale aprirò un quesito con l’helpdesk e per cui ho dovuto usare il tuo workaround.

        Purtroppo, la gestione tramite regexp di “tutti” i file non è completamente affidabile: circa una volta su 200 la “ripulitura” o non riconosce la sequenza di separazione, o mi mangia un carattere in più dal contenuto testuale (o peggio ancora numerico) o da un tag.

        Alla fine mi sono dovuto affidare alla libreria OpenSSL di PHP, e gestire il caso maledetto in cui il file non è ASN1 compliant. In questo caso però c’è una ottima notizia, il payload XML non è diviso in segmenti (la cui lunghezza, a proposito, è variabile: che fosse sempre 1000 era un caso che io, ingenuo, avevo sperato fosse essere regola. Non lo è).

         // $temp è il file da verificare
         $cert = tempnam(sys_get_temp_dir(), 'pem');
         $retn = openssl_pkcs7_verify($temp, PKCS7_NOVERIFY, $cert);
         if (!$retn) {
                        // $logVerify[] = "Error verifying PKCS#7 signature in {$attachment['name']}";
                        unlink($cert);
                        return false;
          }
        
          $fatt = extractDER($temp);
          if (empty($fatt)) {
                        // Allegato 'strano', per ora circa 5% del totale
                        // $logVerify[] = "Error loading XML from P7M";
                        $test   = @base64_decode($attachment['data']);
                        // Salto lo header (INDISPENSABILE perché la regexp funzioni sempre)
                        if (preg_match('#(<[^>]*FatturaElettronica.*</[^>]*FatturaElettronica>)#', substr($test, 54), $gregs)) {
                         // Quando è in questo formato la fattura NON HA INTESTAZIONE XML (!)
                         $fatt   = '<'.'?'.'xml version="1.0"'.'?'.'>' . $gregs[1];
        
                         // ===== Questo serve solo a generare dei warning nel log di cron
                         simplexml_load_string($fatt);
                         print "Fattura P7M recuperata\n";
                         // =====
                        } else {
                            return false;
                        }
            }
        

        La funzione per estrarre dal file DER:

        function extractDER($file) {
        $tmp = tempnam(sys_get_temp_dir(), ‘pem’);
        $txt = tempnam(sys_get_temp_dir(), ‘txt’);
        $flags = PKCS7_BINARY|PKCS7_NOVERIFY|PKCS7_NOSIGS;
        //
        openssl_pkcs7_verify($file, $flags, $tmp);
        // (questo potrebbe fallire se il file non è ASN.1 clean)
        @openssl_pkcs7_verify($file, $flags, ‘/dev/null’, array(), $tmp, $txt);
        unlink($tmp);
        $out = file_get_contents($txt);
        unlink($txt);
        return $out;
        }

        1. Ciao Leonardo stavo guardando il tuo codice. Non mi è chiaro però cos’è per te la variabile $attachment[‘data’]

        2. Ho usato anch’io, openssl per estrarre le fatture non CADES, quello che hai postato funziona con qualsiasi Fattura?
          Grazie

  8. Ciao Ryan, grazie ai tuoi script riesco ad estrarre correttamente l’XML in quasi tutti i file firmati.
    Ho scritto quasi perchè ho un file in particolare che mi crea problemi:

    Come posso mandarti il file nel caso ti serve per un test?

    Nel punto: </DettaglioPagamentoEOT?> la tua funzione me la pulisce in modo errato, cioè mi restituisce: </DettaglioPagamento?> mantenendo quel “?” che mi crea il seguente errore:

    Warning: DOMDocument::loadXML(): expected '&gt;' in Entity
    

    Esempio di utilizzo:

    $fileDecoded = base64_decode($fileBase64); //$fileBase64 recuperato dal web service di aruba
    $xml = new DOMDocument();
    $xml->loadXML(stripP7MData($fileDecoded)); // <— qua mi da errore descritto prima

    Funzioni utilizzate:

    function sanitizeXML($string)
    {
    if (!empty($string))
    {
    mb_regex_encoding(‘UTF-8’);
    $string = mb_eregi_replace(‘(\x{0004}(?:\x{201A}|\x{FFFD})(?:\x{0003}|\x{0004}).)’, ”, $string);

        $regex = '/(
            [\xC0-\xC1] # Invalid UTF-8 Bytes
            | [\xF5-\xFF] # Invalid UTF-8 Bytes
            | \xE0[\x80-\x9F] # Overlong encoding of prior code point
            | \xF0[\x80-\x8F] # Overlong encoding of prior code point
            | [\xC2-\xDF](?![\x80-\xBF]) # Invalid UTF-8 Sequence Start
            | [\xE0-\xEF](?![\x80-\xBF]{2}) # Invalid UTF-8 Sequence Start
            | [\xF0-\xF4](?![\x80-\xBF]{3}) # Invalid UTF-8 Sequence Start
            | (?<=[\x0-\x7F\xF5-\xFF])[\x80-\xBF] # Invalid UTF-8 Sequence Middle
            | (?<![\xC2-\xDF]|[\xE0-\xEF]|[\xE0-\xEF][\x80-\xBF]|[\xF0-\xF4]|[\xF0-\xF4][\x80-\xBF]|[\xF0-\xF4][\x80-\xBF]{2})[\x80-\xBF] # Overlong Sequence
            | (?<=[\xE0-\xEF])[\x80-\xBF](?![\x80-\xBF]) # Short 3 byte sequence
            | (?<=[\xF0-\xF4])[\x80-\xBF](?![\x80-\xBF]{2}) # Short 4 byte sequence
            | (?<=[\xF0-\xF4][\x80-\xBF])[\x80-\xBF](?![\x80-\xBF]) # Short 4 byte sequence (2)
        )/x';
        $string = preg_replace($regex, '', $string);
    
        $result = "";
        $current;
        $length = strlen($string);
        for ($i=0; $i < $length; $i++)
        {
            $current = ord($string{$i});
            if (($current == 0x9) ||
                ($current == 0xA) ||
                ($current == 0xD) ||
                (($current >= 0x20) && ($current <= 0xD7FF)) ||
                (($current >= 0xE000) && ($current <= 0xFFFD)) ||
                (($current >= 0x10000) && ($current <= 0x10FFFF)))
            {
                $result .= chr($current);
            }
            else
            {
                $ret;    // use this to strip invalid character(s)
                // $ret .= " ";    // use this to replace them with spaces
            }
        }
        $string = $result;
    }
    return $string;
    

    }

    function stripP7MData($string) {
    $string = substr($string, strpos($string, ‘<?xml '));
    preg_match_all('//’, $string, $matches, PREG_OFFSET_CAPTURE);
    $lastMatch = end($matches[0]);
    return sanitizeXML(substr($string, 0, $lastMatch[1]+strlen($lastMatch[0])));
    }

  9. Ciao Ryan,

    Stavo usando i tuoi codici iniziali di pulitura ma purtroppo ogni tanto arriva un XML che no viene pulito bene e rimane qualche TAG XML con una lettera che ne compromette le leggibilità.

    Quindi stavo provando la tua nuova funzione extractDER con anche il codice sopra indicato. In effetti anche io non capisco il controllo base64 su $attachment[‘data’] che non vedo dichiarato da nessuna parte, comunque anche saltando questa parte non riesco ad ottenere nulla, ho provato a stampare $fatt in varie parti del codice ma è sempre vuoto, così come sono a 0 byte i vari files creati

    L’xml di prova è uno dei classici con parte aggiunto sopra e sotto e qualche “sporcatura” in mezzo.

    P.S. openssl_pkcs7_verify mi dava problemi con open_basedir del vhost quindi l’ho disattivato per i test

    Hai qualche suggerimento ?
    Grazie
    Ciao

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *


The reCAPTCHA verification period has expired. Please reload the page.

Questo sito usa Akismet per ridurre lo spam. Scopri come i tuoi dati vengono elaborati.