#!/usr/bin/perl -w
#
###########################################################################
#
# File: gpgdir
#
# Purpose:  To encrypt/decrypt whole directories
#
# Author: Michael Rash (mbr@cipherdyne.com)
#
# Version: 0.9.3
#
# 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.35 2005/02/20 20:53:32 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;

### gpgdir version
my $version = '0.9.3';

### establish some defaults
my $encrypt_user;
my $gpg_homedir;
my $dir             = '';
my $pw              = '';
my $encrypt_dir     = '';
my $decrypt_dir     = '';
my $homedir         = '';
my $exclude_pat     = '';
my $exclude_file    = '';
my $include_pat     = '';
my $include_file    = '';
my $total_encrypted = 0;
my $total_decrypted = 0;
my $norecurse       = 0;
my $printver        = 0;
my $no_delete       = 0;
my $no_fs_times     = 0;
my $test_and_exit   = 0;
my $skip_test_mode  = 0;
my $verbose         = 0;
my $help            = 0;
my $encrypt         = 0;
my $pw_file         = '';
my @exclude_patterns = ();
my @include_patterns = ();
my %files            = ();

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

### 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.
    'pw-file=s'      => \$pw_file,        # Read password out of this file.
    'Exclude=s'      => \$exclude_pat,    # Exclude a pattern from encrypt/decrypt
                                          # cycle.
    'Exclude-from=s' => \$exclude_file,   # Exclude patterns in <file> from
                                          # encrypt decrypt cycle.
    'Include=s'      => \$include_pat,    # Specify a pattern used to restrict
                                          # encrypt/decrypt operation to.
    'Include-from=s' => \$include_file,   # Specify a file of include patterns to
                                          # restrict all encrypt/decrypt
                                          # operations to.
    'test-mode'      => \$test_and_exit,  # Run encrypt -> decrypt test only and
                                          # exit.
    'skip-test'      => \$skip_test_mode, # Skip encrypt -> decrypt test.
    'no-recurse'     => \$norecurse,      # Don't encrypt/decrypt files in
                                          # subdirectories.
    'no-delete'      => \$no_delete,      # Don't delete files once they have
                                          # been encrypted.
    'user-homedir=s' => \$homedir,        # Path to home directory.
    'no-preserve-times' => \$no_fs_times, # Don't preserve mtimes or atimes.
    'verbose'        => \$verbose,        # Verbose mode.
    '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;

### get the path to the user's home directory
$homedir = &get_homedir() unless $homedir;

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

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 || $test_and_exit) {
    print "[*] Please specify -e <dir>, -d <dir>, or --test-mode\n";
    &usage_and_exit();
}

### exclude file pattern
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;
    }
}

### include file pattern
push @include_patterns, $include_pat if $include_pat;

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

if ($encrypt_dir) {
    $dir = $encrypt_dir;
    $encrypt = 1;
} elsif ($decrypt_dir) {
    $dir = $decrypt_dir;
    $encrypt = 0;
}

if ($dir) {
    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: $!";

&get_password unless $encrypt and $skip_test_mode;

### run a test to make sure gpgdir and encrypt and decrypt a file
unless ($skip_test_mode) {
    my $rv = &test_mode();
    exit $rv if $test_and_exit;
}

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

if ($encrypt) {
    print "[+] Encrypting directory: $dir\n";
} else {
    print "[+] Decrypting directory: $dir\n";
}

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

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

### build a hash of file paths to work against
&get_files($dir);

### perform the gpg operation (encrypt/decrypt)
&gpg_operation();

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() {

    ### sort by oldest to youngest mtime
    FILE: for my $file (sort
            {$files{$a}{'mtime'} <=> $files{$b}{'mtime'}} keys %files) {

        ### see if we have an exclusion pattern that implies
        ### we should skip this file
        if (@exclude_patterns and &exclude_file($file)) {
            print "[+] Skipping excluded file: $file\n"
                if $verbose;
            next FILE;
        }

        ### see if we have an inclusion pattern that implies
        ### we should process this file
        if (@include_patterns and not &include_file($file)) {
            print "[+] Skipping non-included file: $file\n"
                if $verbose;
            next FILE;
        }

        ### dir is always a full path
        my ($dir, $filename) = ($file =~ m|(.*)/(.*)|);

        unless (chdir($dir)) {
            print "[-] Could not chdir $dir, skipping.\n";
            next FILE;
        }

        my $mtime = $files{$file}{'mtime'};
        my $atime = $files{$file}{'atime'};

        if ($encrypt) {
            if (-e "$filename.gpg") {
                print "[-] Encrypted file $file.gpg already exists. ",
                    "Skipping.\n";
                next FILE;
            }
            print "[+] Encrypting:  $file\n";
            $gpg->encrypt(plaintext=>$filename, output=>"$filename.gpg",
                          recipient=>$encrypt_user);
            if (-e "$filename.gpg" && -s "$filename.gpg" != 0) {
                ### set the atime and mtime to be the same as the
                ### original file.
                unless ($no_fs_times) {
                    if (defined $mtime and $mtime and
                            defined $atime and $atime) {
                        utime $atime, $mtime, "$filename.gpg";
                    }
                }
                $total_encrypted++;
                ### only delete the original file if
                ### the encrypted one exists
                unlink $filename unless $no_delete;
            } else {
                print "[-] Could not encrypt file: $file\n";
                next FILE;
            }
        } else {
            ### allow filenames with spaces
            my ($decrypt_filename) = ($filename =~ /^(.+)\.gpg$/);
            next unless $decrypt_filename;
            ### don't decrypt a file on top of a normal file of
            ### the same name
            if (-e $decrypt_filename) {
                print "[-] Decrypted file $dir/$decrypt_filename ",
                    "already exists. Skipping.\n";
                next FILE;
            }
            print "[+] Decrypting:  $dir/$filename\n";
            $gpg->decrypt(ciphertext=>$filename, output=>$decrypt_filename,
                          passphrase=>$pw);
            if (-e $decrypt_filename && -s $decrypt_filename != 0) {
                ### set the atime and mtime to be the same as the
                ### original file.
                unless ($no_fs_times) {
                    if (defined $mtime and $mtime and
                            defined $atime and $atime) {
                        utime $atime, $mtime, $decrypt_filename;
                    }
                }
                ### only delete the original encrypted
                ### file if the decrypted one exists
                unlink $filename;
                $total_decrypted++;
            } else {
                print "[-] Could not decrypt file: $file\n";
                next FILE;
            }
        }
    }
    print "\n";
    chdir $initial_dir or die "[*] Could not chdir: $initial_dir\n";
    return;
}

sub get_files() {
    my $dir = shift;

    print "[+] Building file list...\n";
    if ($norecurse) {
        opendir D, $dir or die "[*] Could not open $dir: $!";
        my @files = readdir D;
        closedir D;
        shift @files; shift @files;
        for my $file (@files) {
            &check_file_criteria("$dir/$file");
        }
    } else {
        ### get all files in all subdirectories
        find(\&find_files, $dir);
    }
    return;
}

sub exclude_file() {
    my $file = shift;
    for my $pat (@exclude_patterns) {
        if ($file =~ m|$pat|) {
            print "[+] Skipping $file (matches exclude pattern: $pat)\n"
                if $verbose;
            return 1;
        }
    }
    return 0;
}

sub include_file() {
    my $file = shift;
    for my $pat (@include_patterns) {
        if ($file =~ m|$pat|) {
            print "[+] Including $file (matches include pattern: $pat)\n"
                if $verbose;
            return 1;
        }
    }
    return 0;
}

sub get_homedir() {
    my $uid = $<;
    my $homedir = '';
    if (-e '/etc/passwd') {
        open P, '< /etc/passwd' or
            die "[*] Could not open /etc/passwd. Exiting.\n";
        my @lines = <P>;
        close P;
        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;
            }
        }
    } else {
        $homedir = $ENV{'HOME'} if defined $ENV{'HOME'};
    }
    die "[*] Could not determine home directory. Use the -u <homedir> option."
        unless $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_files() {
    my $file = $File::Find::name;
    &check_file_criteria($file);
    return;
}

sub check_file_criteria() {
    my $file = shift;
    ### skip all links, zero size files, all hidden
    ### files (includes .gnupg files), etc.
    if (-d $file) {
        print "[-] Skipping directory: $file\n"
            if $verbose;
        return;
    }
    if (-e $file and not -l $file and -s $file != 0
            and $file !~ m|/\.|) {
        if ($encrypt) {
            if ($file =~ m|\.gpg|) {
                print "[-] Skipping encrypted file: $file\n";
                return;
            }
        } else {
            if ($file !~ m|\.gpg|) {
                print "[-] Skipping unencrypted file: $file\n";
                return;
            }
        }
        my ($atime, $mtime) = (stat($file))[8,9];
        $files{$file}{'atime'} = $atime;
        $files{$file}{'mtime'} = $mtime;
    } else {
        print "[-] Skipping file: $file\n"
            if $verbose;
    }
    return;
}

sub get_password() {
    if ($pw_file) {
        open PW, "< $pw_file" or die "[*] Could not open $pw_file: $!";
        $pw = <PW>;
        close PW;
        chomp $pw;
    } else {
        my $msg = '[+] Enter decryption password: ';
        if ($test_and_exit) {
            $msg = '[+] test_mode(): Enter decryption password: ';
        } elsif ($encrypt) {
            $msg = '[+] Enter decryption password (for initial ' .
                'encrypt/decrypt test): ';
        }
        ### get the password without echoing the chars back to the screen
        ReadMode 'noecho';
        while (! $pw) {
            print $msg;
            $pw = ReadLine 0;
            chomp $pw;
        }
        ReadMode 'normal';
        print "\n\n";
    }
    return;
}

sub test_mode() {
    my $test_file = '/tmp/.gpgdir_test';
    print "[+] test_mode(): Encrypt/Decrypt test of $test_file\n"
        if $test_and_exit or $verbose;

    if (-e $test_file) {
        unlink $test_file or
            die "[*] test_mode(): Could not remove $test_file: $!";
    }
    open G, "> $test_file" or
        die "[*] test_mode(): Could not create $test_file: $!";
    print G "gpgdir test\n";
    close G;

    if (-e $test_file) {
        print "[+] test_mode(): Created $test_file\n"
            if $test_and_exit or $verbose;
    } else {
        die "[*] test_mode(): Could not create $test_file\n";
    }
    my $gpg = new GnuPG(homedir=>$gpg_homedir);
    die "[*] test_mode(): Could not create new gpg object with ",
        "homedir: $gpg_homedir" unless $gpg;

    $gpg->encrypt(plaintext=>$test_file, output=>"${test_file}.gpg",
                  recipient=>$encrypt_user);
    if (-e "$test_file.gpg") {
        print "[+] test_mode(): Successful encrypt of $test_file\n"
            if $test_and_exit or $verbose;
    } else {
        die "[*] test_mode(): not encrypt $test_file\n";
    }
    unlink $test_file if -e $test_file;
    $gpg->decrypt(ciphertext=>"${test_file}.gpg", output=>$test_file,
                  passphrase=>$pw);
    if (-e $test_file) {
        print "[+] test_mode(): Successful decrypt of $test_file\n"
            if $test_and_exit or $verbose;
    } else {
        die "[*] test_mode(): Could not decrypt $test_file.gpg\n";
    }
    open F, "< $test_file" or
        die "[*] test_mode(): Could not open $test_file: $!";
    my $line = <F>;
    close F;
    chomp $line;
    if ($line eq 'gpgdir test') {
        print "[+] test_mode(): Decrypted content matches original.\n",
            "[+] test_mode(): Success!\n\n"
            if $test_and_exit or $verbose;
    } else {
        die "[*] test_mode(): Decrypted content does not match original. Fail!";
    }
    return 1;
}

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

Usage: gpgdir [-e <directory>] [-d <directory>] [-g <directory>] [-p] [-s]
              [--Exclude <pattern>] [--Exclude-from <file>] [--no-recurse]
              [--no-delete] [--no-preserve-times] [-t] [-v] [-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).
    -p, --pw-file <file>        - Read password in from <file>.
    -s, --skip-test             - Skip encrypt -> decrypt test.
    -t, --test-mode             - Run in testing mode and exit.
    --Exclude <pattern>         - Skip all filenames that match <pattern>.
    --Exclude-from <file>       - Skip all filenames that match any pattern
                                  contained within <file>.
    --no-recurse                - Don't recursively encrypt/decrypt
                                  subdirectories.
    --no-delete                 - Don't delete original unencrypted files.
    --no-preserve-times         - Don't preserve original mtime and atime
                                  values on encrypted/decrypted files.
    -v, --verbose               - Run in verbose mode.
    -V, --Version               - print version.
    -h, --help                  - print help.
_HELP_
    exit 0;
}
