#!/usr/bin/env perl
use feature qw(state);
use open qw(:std :utf8);
use strict;
use warnings;
use Archive::Zip qw(:ERROR_CODES);
use Crypt::X509;
use Encode qw(decode);
use File::Basename qw(basename dirname);
use File::Path qw(make_path);
use File::Spec::Functions qw(catfile);
use File::Temp;
use Getopt::Long qw(:config bundling no_ignore_case);
use MIME::Base64;
use Nullroute::Lib;
use XML::XPath;
use XML::Simple;

my %Opt;

sub usage {
    print "$_\n" for
    "Usage: $::arg0 [--format FMT] [--output-dir DIR] FILE...",
    "",                           #
    "  --format FMT               force input format",
    "  -d, --output-dir DIR       store certificates in DIR instead",
    "  FILE                       documents to parse",
}

sub bigint_hex {
    my ($i) = @_;

    if (ref $i eq "Math::BigInt") {
        return $i->as_hex =~ s/^0x//r;
    } else {
        return sprintf("%x", $i);
    }
}

sub split_pem_certs {
    my ($buf) = @_;
    return $buf =~ /^-----BEGIN CERTIFICATE-----\r?\n(.+?)\r?\n-----END CERTIFICATE-----$/msg;
}

sub parse_pkcs7 {
    my ($buf) = @_;

    my $tmp = File::Temp->new;
    $tmp->print($buf);
    $tmp->flush;
    open(my $fh, "-|", "openssl", "pkcs7",
                    "-in", $tmp->filename,
                    "-inform", "DER",
                    "-print_certs",
                    "-quiet");
    my $pems = do { local $/; <$fh> };
    $fh->close;
    $tmp->close;

    return map {decode_base64($_)} split_pem_certs($pems);
}

sub parse_xmldsig {
    my ($data, %opt) = @_;

    my $xs = XML::Simple->new;
    my $xml = $xs->XMLin($data,
                         NSExpand => 1,
                         KeepRoot => $opt{KeepRoot},
                         ForceContent => 1,
                         ForceArray => [
                            "{http://www.w3.org/2000/09/xmldsig#}Signature",
                         ]);

    if ($::debug >= 2) {
        require Data::Dumper;
        print Data::Dumper->new([$xml])->Indent(1)->Dump;
    }

    my @certs;
    if (my $data = $xml->{"{http://www.w3.org/2000/09/xmldsig#}Signature"}) {
        push @certs,
            map {decode_base64($_)}
            grep {defined $_}
            map {$_->{"{http://www.w3.org/2000/09/xmldsig#}KeyInfo"}
                   ->{"{http://www.w3.org/2000/09/xmldsig#}X509Data"}
                   ->{"{http://www.w3.org/2000/09/xmldsig#}X509Certificate"}
                   ->{"content"}}
                @$data;

        push @certs,
            map {parse_pkcs7($_)}
            map {decode_base64($_)}
            grep {defined $_}
            map {$_->{"{http://www.w3.org/2000/09/xmldsig#}Object"}
                   ->{"{http://uri.etsi.org/01903/v1.3.2#}QualifyingProperties"}
                   ->{"{http://uri.etsi.org/01903/v1.3.2#}UnsignedProperties"}
                   ->{"{http://uri.etsi.org/01903/v1.3.2#}UnsignedSignatureProperties"}
                   ->{"{http://uri.etsi.org/01903/v1.3.2#}SignatureTimeStamp"}
                   ->{"{http://uri.etsi.org/01903/v1.3.2#}EncapsulatedTimeStamp"}
                   ->{"content"}}
                @$data;
    } else {
        warn "XML-DSig has no <Signature> tag";
    }
    return @certs;
}

sub parse_xml {
    my ($data, %opt) = @_;

    my $doc = XML::XPath->new(xml => $data);

    my @all = $doc->findnodes("//X509Certificate");

    return map {decode_base64($_->string_value)} @all;
}

sub try_decode {
    my ($str) = @_;

    # best-effort BMPString detection
    if (defined($str) && length($str) % 2 == 0 && $str =~ /^\0/) {
        $str = decode("UTF-16BE", $str);
    }
    return $str;
}

sub safe_filename {
    my ($str) = @_;

    $str = try_decode($str);
    if (defined $str) {
        $str =~ s/[ \/\\"?*<>:]/_/g;
        $str =~ s/[\x00-\x1F]/sprintf("%%%02X", ord($&))/ge;
    }
    return $str;
}

sub make_cert_filename {
    my ($cert) = @_;

    my $issuer = safe_filename($cert->issuer_cn // $cert->issuer_org);
    my $subject = safe_filename($cert->subject_cn // $cert->subject_org);
    my $serial = bigint_hex($cert->serial);

    return $issuer."/".$subject."_".$serial.".crt";
}

sub increment_filename {
    my ($str) = @_;

    if ($str =~ /^(.+?) \((\d+)\)(\.[^.]+)?$/) {
        _debug("head '$1' mid '$2' tail '".($3//"")."'");
        return $1." (".($2+1).")".$3;
    }
    elsif ($str =~ /^(.+?)(\.[^.]+)?$/) {
        _debug("head '$1' tail '".($2//"")."'");
        return $1." (1)".$2;
    }
}

sub write_certificate {
    my ($cert_der) = @_;
    state %seen;

    my $cert = Crypt::X509->new(cert => $cert_der);
    if (!$cert || $cert->{_error}) {
        _err("could not parse certificate");
        return;
    }

    my $subject = try_decode($cert->subject_cn // $cert->subject_org);
    my $issuer = try_decode($cert->issuer_cn // $cert->issuer_org);
    _info("found cert '".$subject."' (".$issuer.")");

    if ($seen{$cert_der}++) {
        _debug("certificate already seen, skipping");
        return;
    }

    my $out_dir = $Opt{output_dir} // ".";
    my $out_file = catfile($out_dir, make_cert_filename($cert));
    overwrite_check:
    if (-e $out_file) {
        if (open(my $f, "<:raw", $out_file)) {
            my $old_data;
            $f->read($old_data, 8192);
            $f->close;
            if ($old_data eq $cert_der) {
                _debug("identical certificate already stored, skipping");
                return;
            } else {
                _debug("different certificate stored, trying next filename");
                $out_file = catfile($out_dir, increment_filename($out_file));
                goto overwrite_check;
            }
        }
    }
    make_path(dirname($out_file));
    _info("writing '$out_file'");
    if (open(my $f, ">:raw", $out_file)) {
        $f->print($cert_der);
        $f->close;
    } else {
        _err("could not open '$out_file': $!");
    }
}

sub process_xml {
    my ($adoc, $parse_func) = @_;

    if (open(my $f, "<:raw", $adoc)) {
        my @cert_der = $parse_func->($f);
        if (@cert_der) {
            write_certificate($_) for @cert_der;
        } else {
            _err("no certificates found in '$adoc'");
        }
        $f->close;
    }
}

sub process_zip {
    my ($adoc, $sig_regex, $parse_func) = @_;

    my $zip = Archive::Zip->new;
    if ($zip->read($adoc) != AZ_OK) {
        _die("could not read file '$adoc'");
    }

    my @sigs = $zip->membersMatching($sig_regex);
    for my $sig_file (@sigs) {
        _debug("- found sig file '".$sig_file->{fileName}."'");
        my $data = $zip->contents($sig_file);
        my @cert_der = $parse_func->($data);
        if (@cert_der) {
            write_certificate($_) for @cert_der;
        } else {
            _err("no certificates found in '$adoc' [".$sig_file->{fileName}."]");
        }
    }
}

my $pem_re = qr/^-----BEGIN[ ]CERTIFICATE-----\r?\n
                ([A-Za-z0-9\/+\r\n]+ =*)\r?\n
                -----END[ ]CERTIFICATE-----\r?$/msx;

my $pdf_extractor = $ENV{HOME}."/src/projects/pdf-extract-certs/pdf-extract-certs.jar";

utf8::decode($_) for @ARGV;

GetOptions(
    "help" => sub { usage(); exit; },
    "f|format=s" => \$Opt{force_format},
    "d|output-dir=s" => \$Opt{output_dir},
) or exit(2);

for my $adoc (@ARGV) {
    my $format;

    if (! -e $adoc) {
        _err("skipping '$adoc': does not exist");
    }
    elsif ($Opt{force_format}) {
        $format = $Opt{force_format};
    }
    elsif ($adoc =~ /\.(adoc|bdoc|asice|sce)$/i) {
        $format = "asic-e";
    }
    elsif ($adoc =~ /\.od[pst]$/i) {
        $format = "opendocument";
    }
    elsif ($adoc =~ /\.(doc|xls|ppt)[xm]$/i) {
        $format = "ooxml";
    }
    elsif ($adoc =~ /\.(ddoc|xml)$/i) {
        $format = "xml-dsig";
    }
    elsif ($adoc =~ /\.pdf$/i) {
        $format = "pdf";
    }
    elsif ($adoc =~ /\.(crt|cert|cer|der|pem)$/i) {
        $format = "x509";
    }
    elsif ($adoc =~ /\.(p7b|p7c)$/i) {
        $format = "pkcs7";
    }
    else {
        _err("skipping '$adoc': unknown file format");
    }

    if (!$format) {
        next;
    }
    elsif ($format =~ /^(adoc|bdoc|asic-e)$/) {
        _log2("parsing '$adoc' (ADOC or ASiC-E)");
        process_zip($adoc,
                    "^META-INF/(signatures/)?signatures.*\\.xml\$",
                    \&parse_xmldsig);
    }
    elsif ($format eq "opendocument") {
        _log2("parsing '$adoc' (OpenDocument)");
        process_zip($adoc,
                    "^META-INF/documentsignatures\\.xml\$",
                    \&parse_xmldsig);
    }
    elsif ($format eq "ooxml") {
        _log2("parsing '$adoc' (Office Open XML)");
        process_zip($adoc,
                    "^_xmlsignatures/sig\\d+\\.xml\$",
                    sub { parse_xmldsig(@_, KeepRoot => 1) });
    }
    elsif ($format eq "digidoc" || $format eq "xml-dsig") {
        _log2("parsing '$adoc' (DigiDoc or raw XML-DSig)");
        process_xml($adoc, \&parse_xmldsig);
    }
    elsif ($format eq "xml") {
        _log2("parsing '$adoc' (XML)");
        process_xml($adoc, \&parse_xml);
    }
    elsif ($format eq "pdf") {
        _log2("parsing '$adoc' (PDF)");
        _debug("running tool '$pdf_extractor'");
        if (open(my $f, "-|", "java", "-jar", $pdf_extractor, $adoc)) {
            my $data;
            $f->read($data, 128*1024);
            $f->close;
            while ($data =~ /$pem_re/g) {
                my $cert_der = decode_base64($1);
                write_certificate($cert_der);
            }
        } else {
            _err("could not run '$pdf_extractor': $!");
        }
    }
    elsif ($format eq "x509") {
        _log2("parsing bare certificate '$adoc'");
        if (open(my $f, "<:raw", $adoc)) {
            my $data;
            $f->read($data, 8*1024);
            $f->close;
            if ($data =~ /^-----/m) {
                while ($data =~ /$pem_re/g) {
                    my $cert_der = decode_base64($1);
                    write_certificate($cert_der);
                }
            } else {
                write_certificate($data);
            }
        } else {
            _err("could not open '$adoc': $!");
        }
    }
    elsif ($format eq "pkcs7") {
        _log2("parsing PKCS#7 file '$adoc'");
        if (open(my $f, "<:raw", $adoc)) {
            my $inform = "DER";
            my $data;
            $f->read($data, 8*1024);
            $f->close;
            if ($data =~ /^-----/m) {
                $inform = "PEM";
            }
            if (open(my $f, "-|", "openssl", "pkcs7", "-in", $adoc,
                                                      "-inform", $inform,
                                                      "-print_certs")) {
                my $data;
                $f->read($data, 1024*1024);
                $f->close;
                while ($data =~ /$pem_re/g) {
                    my $cert_der = decode_base64($1);
                    write_certificate($cert_der);
                }
            }
        } else {
            _err("could not open '$adoc': $!");
        }
    }
    else {
        _err("unknown file format '$format'");
    }
}

_exit();

# vim: ts=4:sw=4:et
