#!/usr/bin/perl -w
#
###########################################################################
#
# File: gpgdir
#
# Purpose:  To encrypt/decrypt whole directories
#
# Author: Michael B. Rash (mbr@ciphedyne.com)
#
# Version: 0.9
#
# License (GNU General Public License):
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program; if not, write to the Free Software
#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307
#    USA
#
###########################################################################
#
# $Id: gpgdir,v 1.29 2004/09/03 01:32:06 mbr Exp $
#

use lib '/usr/lib/gpgdir';
use File::Find;
use File::Copy;
use Term::ReadKey;
use GnuPG;
use Getopt::Long;
use Cwd;
use strict;

#==================== config =======================
my $version = '0.9';

### system binaries
my $gunzipCmd = '/bin/gunzip';
my $gzipCmd   = '/bin/gzip';
#================== end config =====================

### establish some defaults
my $encrypt_user;
my $gpg_homedir;
my $dir;
my @subdirs;
my $pw;
my $encrypt;
my $encrypt_dir;
my $decrypt_dir;
my $exclude_pat     = '';
my $exclude_file    = '';
my $total_encrypted = 0;
my $total_decrypted = 0;
my $norecurse       = 0;
my $compress        = 0;
my $uncompress      = 0;
my $printver        = 0;
my $no_delete       = 0;
my $pw_file         = '';
my @exclude_patterns = ();
my $help;

my %Cmds = (
    'gzip'   => $gzipCmd,
    'gunzip' => $gunzipCmd,
);

my %err_msgs = (
    'exclude'        => 'file matched exclude pattern',
    'link'           => 'file is a link',
    'directory'      => 'directory skip',
    'zero'           => 'zero byte file',
    'hidden'         => 'hidden file',
    'encrypted'      => 'previously encrypted',
    'bad_compress'   => 'failed compress',
    'bad_encrypt'    => 'failed encrypt',
    'decrypted'      => 'previously decrypted',
    'bad_decompress' => 'failed decompress',
    'bad_decrypt'    => 'failed decrypt'
);

### make sure we can find the system binaries
### in the expected locations.
&check_commands();

open STDERR, ">&STDOUT" || die '[*] Could not dup STDERR to STDOUT';

unless ($< == $>) {
    die "[*] Real and effective uid must be the same.  Make sure\n",
        "    gpgdir has not been installed as a SUID binary.\n",
        "Exiting.\n";
}

### get the path to the .gnupg directory in the user's home directory
my $homedir = &get_homedir();

### get the key identifier from ~/.gnupg
$encrypt_user = &get_key();

### make Getopts case sensitive
Getopt::Long::Configure('no_ignore_case');

&usage_and_exit() unless(GetOptions (
    'encrypt=s'      => \$encrypt_dir,  # Encrypt files in this directory.
    'decrypt=s'      => \$decrypt_dir,  # Decrypt files in this directory.
    'gnupg-dir=s'    => \$gpg_homedir,  # Path to /path/to/.gnupg directory.
    'Exclude=s'      => \$exclude_pat,  # Exclude a pattern from encrypt/decrypt
                                        # cycle.
    'Exclude-from=s' => \$exclude_file, # Exclude patterns in <file> from 
                                        # encrypt decrypt cycle.
    'compress'       => \$compress,     # Compress files (encrypt phase).
    'uncompress'     => \$uncompress,   # Compress files (decrypt phase).
    'no-recurse'     => \$norecurse,    # Don't encrypt/decrypt files in
                                        # subdirectories.
    'no-delete'      => \$no_delete,    # Don't delete files once they have
                                        # been encrypted.
    'pw-file=s'      => \$pw_file,      # Read password out of this file.
    'version'        => \$printver,     # Print version
    'help'           => \$help          # Print help
));
&usage_and_exit() if $help;

print "[+] gpgdir version: $version by Michael Rash " .
    "<mbr\@cipherdyne.org>\n" and exit 0 if $printver;

if ($gpg_homedir) {  ### it was specified on the command line with --gnupg-dir
    unless ($gpg_homedir =~ /\.gnupg$/) {
        die "[*] Must specify the path to a user .gnupg directory ",
            "e.g. /home/user/.gnupg\n";
    }
} else {
    if (-d "${homedir}/.gnupg") {
        $gpg_homedir = "${homedir}/.gnupg";
    }
}
unless (-d $gpg_homedir) {
    die "[*] GPG directory: ${homedir}/.gnupg does not exist.  Please\n",
        "    create it by executing: \"gpg --gen-key\".  Exiting.\n";
}

if ($decrypt_dir && $encrypt_dir) {
    die "[*] You cannot encrypt and decrypt the same directory.\n";
    &usage_and_exit();
}

unless ($decrypt_dir || $encrypt_dir) {
    print "[*] Please specify a directory to encrypt or decrypt.\n";
    &usage_and_exit();
}

if ($encrypt_dir && $uncompress) {
    print "[*] The --uncompress option is only compatible with --decrypt.\n";
    &usage_and_exit();
}

if ($decrypt_dir && $compress) {
    print "[*] The --compress option is only compatible with --encrypt.\n";
    &usage_and_exit();
}

push @exclude_patterns, $exclude_pat if $exclude_pat;

if ($exclude_file) {
    open P, "< $exclude_file" or die "[*] Could not open file: $exclude_file";
    my @lines = <P>;
    close P;
    for my $line (@lines) {
        next unless $line =~ /\S/;
        chomp $line;
        push @exclude_patterns, $line;
    }
}

my $gpg = new GnuPG(homedir=>$gpg_homedir);

die "[*] Could not create new gpg object with ",
    "homedir: $gpg_homedir" unless $gpg;

if ($encrypt_dir) {
    $dir = $encrypt_dir;
    $encrypt = 1;
} elsif ($decrypt_dir) {
    ### get the password without echoing the chars back to the screen
    $dir = $decrypt_dir;
    if ($pw_file) {
        open PW, "< $pw_file" or die "[*] Could not open $pw_file: $!";
        $pw = <PW>;
        close PW;
        chomp $pw;
    } else {
        ReadMode 'noecho';
        while (! $pw) {
            print "[+] Enter password to decrypt directory $dir: ";
            $pw = ReadLine 0;
            chomp $pw;
        }
        ReadMode 'normal';
        print "\n";
    }
    $encrypt = 0;
}

die "[*] Directory does not exist: $dir" unless -e $dir;
die "[*] Not a directory: $dir" unless -d $dir;

my $initial_dir = cwd or die "[*] Could not get CWD: $!";

$dir =~ s|/$||;  ### remove any trailing slash
if ($dir !~ m|^/|) {
    $dir = $initial_dir . '/' . $dir;
}

unless ($norecurse) {
    ### find all subdirectories of $dir
    find(\&find_subdirs, $dir);
    for my $subdir (@subdirs) {
        ### perform the encrypt/decrypt operation on the directory
        &gpg_operation($subdir);
    }
} else {
    ### perform the encrypt/decrypt operation only on the
    ### specific directory
    &gpg_operation($dir);
}

if ($encrypt) {
    print "[+] Total number of files encrypted: " .
        "$total_encrypted\n";
} else {
    print "[+] Total number of files decrypted: " .
        "$total_decrypted\n";
}
print "[+] Finished.\n";

exit 0;
#==================== end main =====================

sub gpg_operation() {
    my $dir = shift;
    die "[*] Directory: $dir does not exist.\n" unless -d $dir;

    if ($dir =~ m|\.gnupg|) {  ### don't encrypt any .gnupg directories!!!
        print "[-] Skipping directory: $dir\n";
        return;
    }
    chdir $dir or die "[*] Could not chdir: $dir\n";
    opendir D, '.' or die "[*] Could not open $dir";
    my @files = readdir D;
    closedir D;

    shift @files; shift @files;

    if ($encrypt) {
        print "[+] ==> Encrypting <==  $dir\n";
    } else {
        print "[+] ==> Decrypting <==  $dir\n";
    }

    my %errs;
    FILE: for my $file (@files) {
        next FILE unless $file =~ /\S/ && -e $file;
        if (@exclude_patterns) {
            for my $pat (@exclude_patterns) {
                if ("$dir/$file" =~ m|$pat|) {
                    $errs{'exclude'}{$file} = $pat;
                    next FILE;
                }
            }
        }
        if (-l $file) {
            $errs{'link'}{$file} = '';
            next FILE;
        } elsif (-d $file) {
#            $errs{'directory'}{$file} = '';
            next FILE;
        } elsif (-s $file == 0) {
            $errs{'zero'}{$file} = '';
            next FILE;
        } elsif ($file =~ m|^\.|) {  ### don't encrypt hidden files
            $errs{'hidden'}{$file} = '';
            next FILE;
        }
        if ($encrypt) {
            ### NOTE: encrypted files cannot be modified, so go ahead and
            ### encrypt $file over any existing $file.gpg encrypted file.
            if ($file =~ /\.gpg$/) {
                $errs{'encrypted'}{$file} = '';
            } else {
                my $compressed = 0;
                if ($compress) {
                    unless ($file =~ m|\.gz$|) {
                        print "[+] Compressing:  $file\n";
                        system "$Cmds{'gzip'} $file";
                        if (-e "${file}.gz") {
                            $compressed = 1;
                            $file = "${file}.gz";
                        } else {
                            $errs{'bad_compress'}{$file} = '';
                            next FILE;
                        }
                    }
                }
                print "[+] Encrypting:  $file\n" .
                $gpg->encrypt(plaintext=>$file, output=>"${file}.gpg",
                              recipient=>$encrypt_user);
                if (-e "${file}.gpg" && -s "${file}.gpg" != 0) {
                    $total_encrypted++;
                    ### only delete the original file if
                    ### the encrypted one exists
                    unlink $file unless $no_delete;
                } else {
                    $errs{'bad_encrypt'}{$file} = '';
                }
            }
        } else {
            my ($filename) = ($file =~ /^(\S+)\.gpg$/);
            next unless $filename;
            if (-e $filename) {
                $errs{'decrypted'}{$file} = '';
            } else {
                ### don't decrypt a file on top of a normal file of
                ### the same name
                print "[+] Decrypting:  $file\n";
                $gpg->decrypt(ciphertext=>$file, output=>$filename,
                              passphrase=>$pw);
                if (-e $filename && -s $filename != 0) {
                    ### only delete the original encrypted
                    ### file if the decrypted one exists
                    unlink $file;
                    $total_decrypted++;
                    if ($uncompress && $filename =~ /(\S+)\.gz/) {
                        my $ufile = $1;
                        print "[+] Uncompressing:  $filename\n";
                        system "$Cmds{'gunzip'} $filename";
                        unless (-e $ufile) {
                            $errs{'bad_gunzip'}{$file} = '';
                        }
                    }
                } else {
                    $errs{'bad_decrypt'}{$file} = '';
                }
            }
        }
    }
    if (%errs) {
        print "[+] Errors/Warnings:\n";
        for my $problem (keys %errs) {
            for my $file (keys %{$errs{$problem}}) {
                if ($problem eq 'exclude') {
                    my $pat = $errs{$problem}{$file};
                    print "[-] Error: \"$err_msgs{$problem}: $pat\": $file\n";
                } else {
                    print "[-] Error: \"$err_msgs{$problem}\": $file\n";
                }
            }
        }
    }
    print "\n";
    chdir $initial_dir or die "[*] Could not chdir: $initial_dir\n";
    return;
}

sub get_homedir() {
    my $uid = $<;
    open P, "< /etc/passwd" or die "[*] Could not open /etc/passwd.  ",
        "Exiting.\n";
    my @lines = <P>;
    close P;
    my $homedir = '';
    for my $line (@lines) {
        ### mbr:x:222:222:Michael Rash:/home/mbr:/bin/bash
        chomp $line;
        if ($line =~ /^(?:.*:){2}$uid:(?:.*:){2}(\S+):/) {
            $homedir = $1;
            last;
        }
    }
    die "[*] Could not extract homedir from /etc/passwd for ",
        "uid: $uid.  Exiting.\n" unless $homedir;
    die "[*] homedir: $homedir does not exist.  ",
        "Exiting.\n" unless -d $homedir;
    return $homedir;
}

sub get_key() {
    if (-e "${homedir}/.gpgdirrc") {
        open F, "< ${homedir}/.gpgdirrc" or die "[*] Could not open ",
            "${homedir}/.gpgdirrc.  Exiting.\n";
        my @lines = <F>;
        close F;
        my $key = '';
        for my $line (@lines) {
            chomp $line;
            if ($line =~ /^use_key\s+(\w{8})$/) {
                $key = $1;
                last;
            }
        }
        if ($key && $key =~ /\S/) {
            return $key;
        } else {
            die "[*] Please edit ${homedir}/.gpgdirrc to include your gpg key ",
                "identifier\n    (e.g. \"D4696445\").  See the output of ",
                "\"gpg --list-keys\"\n";
        }
    } else {
        print "[+] Creating gpgdir rc file: ${homedir}/.gpgdirrc\n";
        open F, "> ${homedir}/.gpgdirrc" or die "[*] Could not open " .
            "${homedir}/.gpgdirrc.  Exiting.\n";
        print F "# Config file for gpgdir.\n",
            "# Set the key to use to encrypt files with\n",
            "# \"use_key <key>\", e.g. \"use_key D4696445\".\n",
            "# See \"gpg --list-keys\" for a list of keys.\n\n",
            "# Uncomment and replace \"KEYID\" with your real ",
            "key id on the next line:\n",
            "#use_key KEYID\n";
        close F;
        die "[*] Please edit ${homedir}/.gpgdirrc to include " .
            "your gpg key identifier.  Exiting.\n";
    }
    return;
}

sub find_subdirs() {
    my $file = $File::Find::name;
    if (-d $file && -e $file) {
        push @subdirs, $file;
    }
    return;
}

### check paths to commands and attempt to correct if any are wrong.
sub check_commands() {
    my @path = qw(
        /bin
        /sbin
        /usr/bin
        /usr/sbin
        /usr/local/bin
        /usr/local/sbin
    );
    CMD: for my $cmd (keys %Cmds) {
        unless (-x $Cmds{$cmd}) {
            my $found = 0;
            PATH: for my $dir (@path) {
                if (-x "${dir}/${cmd}") {
                    $Cmds{$cmd} = "${dir}/${cmd}";
                    $found = 1;
                    last PATH;
                }
            }
            unless ($found) {
                die "[*] Could not find $cmd anywhere!!!  ",
                    "Please edit the config section to include the path to ",
                    "$cmd.\n";
            }
        }
        unless (-x $Cmds{$cmd}) {
            die "[*] $cmd is located at ",
                "$Cmds{$cmd} but is not executable by uid: $<\n";
        }
    }
    return;
}

sub usage_and_exit() {
    print <<_HELP_;
gpgdir
    version: $version, by Michael Rash (mbr\@cipherdyne.org)

Usage: gpgdir [-e <directory>] [-d <directory>] [-g <directory>] [-c] [-u]
              [-n] [-p] [-v] [-h]

Options:
    -e, --encrypt <directory>   - Encrypt <directory> and all of its
                                  subdirectories.
    -d, --decrypt <directory>   - Decrypt <directory> and all of its
                                  subdirectories.
    -g, --gnupg-dir <directory> - Specify a path to a .gnupg directory for
                                  gpg keys (the default is ~/.gnupg if this
                                  option is not used).
    -n, --no-recurse            - Don't recursively encrypt/decrypt
                                  subdirectories.
    --Exclude <pattern>         - Skip all filenames that match <pattern>.
    --Exclude-from <file>       - Skip all filenames that match any pattern
                                  contained within <file>.
    -c, --compress              - Compress files when running in --encrypt
                                  mode.
    -u, --uncompress            - Uncompress files when running in --decrypt
                                  mode.
    -p, --pw-file <file>        - Read password in from <file>.
    -v, --version               - print version.
    -h, --help                  - print help.
_HELP_
    exit 0;
}
