@@ -1,6 +1,6 @@
#!/usr/bin/perl
# gpgpwd
-# Copyright (C) Eskild Hustvedt 2012
+# Copyright (C) Eskild Hustvedt 2012, 2013, 2014
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -22,28 +22,35 @@
use JSON qw(encode_json decode_json);
use Try::Tiny;
use IPC::Open2 qw(open2);
-use File::Copy qw(move);
+use IPC::Open3 qw(open3);
+use MIME::Base64 qw(encode_base64 decode_base64);
+use IO::Select;
+use File::Copy qw(move copy);
use File::Basename qw(basename dirname);
-use Cwd qw(getcwd);
+use Term::ReadLine;
+use Cwd qw(getcwd realpath);
use constant {
true => 1,
false => undef,
V_INFO => 1,
V_LOG => 2,
+ V_DEBUG => 3,
};
-my $VERSION = '0.2';
-my @gpg = qw(gpg --gnupg --default-recipient-self --no-verbose --quiet --personal-compress-preferences zlib);
+my $VERSION = '0.4';
+my @gpg = qw(gpg --gnupg --default-recipient-self --no-verbose --quiet --personal-compress-preferences uncompressed);
my $storagePath = $ENV{HOME}.'/.gpgpwddb';
-my $dataVersion = 1;
+my $dataVersion = 2;
my $enableGit = false;
my $forceUnsafe = false;
my $requireAgent = false;
my $useXclip = true;
-my $useGpgAgent = true;
my $verbosity = 0;
+my $allMatches = false;
+my $autoGPGAgent;
my @clipboardTargets = qw(clipboard);
+my $pDieUsepExit = 1;
# Purpose: print() wrapper that obeys $verbosity
# Usage: printv(LEVEL, ...);
@@ -54,14 +61,38 @@
if ($verbosity >= $level)
{
my @prefixes;
- $prefixes[V_INFO] = 'info';
- $prefixes[V_LOG] = 'log';
- printf('[gpgpwd %-4s] ',$prefixes[$level]);
+ $prefixes[V_INFO] = 'info';
+ $prefixes[V_LOG] = 'log';
+ $prefixes[V_DEBUG] = 'debug';
+ printf('[gpgpwd %-5s] ',$prefixes[$level]);
print @_;
}
}
-# Purpose: system() wrapper that outputs the command if $verboity >= V_LOG
+# Purpose: Outputs a status message during initialization
+# Usage: statusOut(message)
+#
+# This is silent if verbosity is 0
+sub statusOut
+{
+ # Ignore status messages if verbosity != 0
+ if ($verbosity == 0)
+ {
+ my $message = shift;
+ state $prevLength = 0;
+ # Clear the previous message
+ print "\r";
+ for(my $i = $prevLength; $i > 0;$i--)
+ {
+ print " ";
+ }
+ # Output the new message
+ print "\r$message";
+ $prevLength = length($message);
+ }
+}
+
+# Purpose: system() wrapper that outputs the command if $verbosity >= V_LOG
# Usage: Same as system()
sub psystem
{
@@ -76,6 +107,10 @@
# Usage: Same as die()
sub pDie
{
+ if (!$pDieUsepExit)
+ {
+ die(@_);
+ }
warn(@_);
pExit(254);
}
@@ -85,6 +120,13 @@
sub pExit
{
my $ret = shift;
+ # Gently kill our started gpg agent if needed
+ if (defined($autoGPGAgent) && $autoGPGAgent > -1)
+ {
+ my @GPGInfo = split(':',$ENV{GPG_AGENT_INFO});
+ kill('SIGINT',$GPGInfo[1]);
+ printv(V_DEBUG,'Killed our gpg agent ('.$GPGInfo[1].')'."\n");
+ }
if (-e $storagePath.'~' && ! -e $storagePath)
{
print 'Note: while exiting '.$storagePath.'~ existed, but '.$storagePath.' did not.'."\n";
@@ -98,7 +140,7 @@
# Usage: InPath(FILE)
sub InPath
{
- foreach (split /:/, $ENV{PATH}) { if (-x "$_/@_" and ! -d "$_/@_" ) { return "$_/@_"; } } return false;
+ foreach (split ':', $ENV{PATH}) { if (-x "$_/@_" and ! -d "$_/@_" ) { return "$_/@_"; } } return false;
}
# Purpose: Copy a string to the clipboard if possible
@@ -109,7 +151,7 @@
# was copied to the clipboard.
sub toClipboard
{
- my $value = shift;
+ my $value = shift;
my $returnString = shift;
if (!$useXclip)
{
@@ -121,7 +163,14 @@
}
if (!InPath('xclip') || !(defined($ENV{DISPLAY}) && length($ENV{DISPLAY})))
{
- printv(V_LOG,'Use of xclip disabled: Either not installed, or no DISPLAY set'."\n");
+ if(InPath('xclip'))
+ {
+ printv(V_LOG,'Use of xclip disabled: no DISPLAY set'."\n");
+ }
+ else
+ {
+ printv(V_LOG,'Use of xclip disabled: not installed'."\n");
+ }
if ($returnString)
{
return '';
@@ -141,120 +190,441 @@
}
# Purpose: Get a random password
-# Usage: $randomPwd = randomPwd();
+# Usage: $randomPwd = randomPwd(length = 15, alphaNumOnly = false);
sub randomPwd
{
my $length = shift;
- $length //= 15;
- my $pwd = '';
- # These characters are chosen specifically because they are usually selectable in
- # a terminal by simply double-clicking on the string.
- my @chars = ('a'..'z','A'..'Z',0..9,',','.','/','?','%','&','#',':','_','=','+','@','~');
- while(length($pwd) < $length)
+ my $alphaNumOnly = shift;
+ $length //= 15;
+ my $pwd = '';
+ while(1)
{
- $pwd .= $chars[ rand scalar @chars ];
+ # These characters are chosen specifically because they are usually selectable in
+ # a terminal by simply double-clicking on the string.
+ my @chars = ('a'..'z','A'..'Z',0..9);
+ if (!$alphaNumOnly)
+ {
+ push(@chars,',','.','/','?','%','&','#',':','_','=','+','@','~');
+ }
+ while(length($pwd) < $length)
+ {
+ $pwd .= $chars[ rand scalar @chars ];
+ }
+ # Require a password to have at least one number, one lower- and one
+ # upper-case character, and one non-word (symbol) character
+ if ($pwd =~ /\d+/ && $pwd =~ /[A-Z]/ && $pwd =~ /[a-z]/ && ($alphaNumOnly || $pwd =~ /\W/))
+ {
+ last;
+ }
+ # Password not accepted, so reset and try again
+ $pwd = '';
}
return $pwd;
}
-# Purpose: Read a file, decrypting it using gpg
-# Usage: gpgIn(PATH);
-# PATH is the path to the file to read
-sub gpgIn
+# Purpose: Check for gpg-agent and start one if needed
+# Usage: autoInitGPGAgent()
+sub autoInitGPGAgent
{
- my $path = shift;
- my $content;
+ if (defined($autoGPGAgent))
+ {
+ return;
+ }
+ elsif(!gpgAgentRunning())
+ {
+ if(InPath('gpg-agent'))
+ {
+ printv(V_DEBUG,'Autostarting gpg-agent'."\n");
+ # Start the agent
+ $autoGPGAgent = open2(my $out, my $in, qw(gpg-agent --daemon));
+ # Read the first line containing the GPG_AGENT_INFO variable
+ my $info = <$out>;
+ # Parse out the variable contents
+ $info =~ s/GPG_AGENT_INFO=(\S+); export GPG_AGENT_INFO/$1/;
+ chomp($info);
+ # Set the GPG_AGENT_INFO environment variable for gpg to use
+ $ENV{GPG_AGENT_INFO} = $info;
+ printv(V_DEBUG,'gpg-agent['.$autoGPGAgent.'] started and listening at '.$info."\n");
+ # Disable tty
+ if (!$requireAgent)
+ {
+ printv(V_DEBUG,'Enabling agent requirement (--no-tty)'."\n");
+ $requireAgent = true;
+ push(@gpg,'--no-tty');
+ # Explicitly enable the agent for gpg1
+ if ($gpg[0] ne 'gpg2')
+ {
+ push(@gpg,'--use-agent');
+ }
+ }
+ }
+ else
+ {
+ # If we have no gpg-agent then the user will get tired of typing
+ # their password fast, so output a message on how to avoid that.
+ print "NOTICE: No gpg-agent available. Install gpg-agent to avoid unneccesary\npassword prompts\n";
+ $autoGPGAgent = -1;
+ }
+ }
+ elsif (!defined($autoGPGAgent))
+ {
+ printv(V_DEBUG,'Not autostarting gpg-agent: already appears to be running'."\n");
+ $autoGPGAgent = -1;
+ }
+}
- if (!$requireAgent)
+# Purpose: Check if a gpg agent is running
+# Usage: bool = gpgAgentRunning()
+sub gpgAgentRunning
+{
+ if (defined($ENV{GPG_AGENT_INFO}) && length($ENV{GPG_AGENT_INFO}))
{
- print '-- gpg --'."\n";
+ my @GPGInfo = split(':',$ENV{GPG_AGENT_INFO});
+ if (-e $GPGInfo[0] && kill(0,$GPGInfo[1]))
+ {
+ return 1;
+ }
}
+ return;
+}
- printv(V_LOG,'Reading data using: '.join(' ',@gpg).' --decrypt '.$path."\n");
- open(my $in,'-|',@gpg,'--decrypt',$path) or pDie('Failed to open up communication with gpg: '.$!."\n");
- while(<$in>)
+# Purpose: Write some data to gpg and return the output from gpg
+# Usage: gpgIO(data,[ gpgCommands ], requiresUnlocked = false, captureSTDERR = false)
+# data is the data to be written to gpg
+# gpgCommands is an arrayref of parameters for gpg
+# requiresUnlocked is a boolean. Set it to true if the provided gpgCommands require
+# the key to be unlocked
+# captureSTDERR is a boolean. Set it to true to capture both stdout and stderr
+sub gpgIO
+{
+ my $data = shift;
+ my $commandList = shift;
+ my $requiresUnlocked = shift;
+ my $captureSTDERR = shift;
+ my @commands = @{$commandList};
+
+ my $output;
+
+ # Autostart a gpg-agent for us if needed
+ autoInitGPGAgent();
+
+ # Declare I/O variables
+ my($in,$out,$err,$pid,$outputReader);
+
+ # Generate the complete gpg commandline
+ my @gpgCommand = @gpg;
+ push(@gpgCommand,@commands);
+
+ # If we're not requiring an agent then gpg will output some messages. To
+ # differenciate it from "our" output, encase it in a text block.
+ if (!$requireAgent && $requiresUnlocked)
{
- $content .= $_;
+ print '-- gpg --'."\n";
+ }
+ # Use open3 to capture stdout+stderr
+ if ($captureSTDERR)
+ {
+ printv(V_LOG,'Talking to gpg with open3: '.join(' ',@gpgCommand)."\n");
+ $pid = open3($in,$out,$err,@gpgCommand) or pDie('Failed to open3() up communication with gpg: '.$!."\n");
+ # Create an IO::Select handle that we use to query for pending data
+ # on the STDOUT and STDERR filehandles
+ $outputReader = IO::Select->new($out,$err);
+ }
+ # Use open2 to capture stdout
+ elsif(defined($data))
+ {
+ printv(V_LOG,'Talking to gpg with open2: '.join(' ',@gpgCommand)."\n");
+ $pid = open2($out,$in,@gpgCommand) or pDie('Failed to open2() up communication with gpg: '.$!."\n");
+ # Create an IO::Select handle that we use to query for pending data
+ # on the STDOUT filehandle
+ $outputReader = IO::Select->new($out);
+ }
+ else
+ {
+ printv(V_LOG,'Talking to gpg with open: '.join(' ',@gpgCommand)."\n");
+ $pid = open($out,'-|',@gpgCommand) or pDie('Failed to open() up communication with gpg: '.$!."\n");
+ # Create an IO::Select handle that we use to query for pending data
+ # on the STDOUT filehandle
+ $outputReader = IO::Select->new($out);
+ }
+ # If we have data and it is an arrayref, print it line-by-line
+ if(defined($data) && ref($data) eq 'ARRAY')
+ {
+ foreach my $l (@{$data})
+ {
+ print {$in} $l."\n" or pDie('Failed to write data to gpg for output: '.$!."\n");
+ # Check if there is pending output data that we need to read
+ while(my @handles = $outputReader->can_read(0))
+ {
+ foreach my $handle (@handles)
+ {
+ $output .= <$handle>;
+ }
+ }
+ }
+ }
+ # Dump the entire data string if we have one
+ elsif(defined($data))
+ {
+ print {$in} $data or pDie('Failed to write data to gpg for output: '.$!."\n");
+ }
+ # Close the input filehandle
+ if(defined($in))
+ {
+ close($in) or do {
+ warn('Failed to close communication with gpg: '.$!."\n");
+ };
+ }
+ # Read all pending output
+ while(my @handles = $outputReader->can_read(120))
+ {
+ foreach my $handle (@handles)
+ {
+ my $read = false;
+ while(my $data = <$handle>)
+ {
+ $read = true;
+ $output .= $data;
+ }
+ if (!$read)
+ {
+ $outputReader->remove($handle);
+ last;
+ }
+ }
}
- if (!$requireAgent)
+ # Close the output handle
+ close($out) or do {
+ warn('Failed to close communication with gpg: '.$!."\n");
+ };
+ # Close the stderr handle if needed
+ if(defined($err))
{
- print '-- --- --'."\n";
+ close($err) or do {
+ warn('Failed to close communication with gpg: '.$!."\n");
+ };
}
- if (!$verbosity)
+ # If we're not requiring an agent then gpg will output some messages. To
+ # differenciate it from "our" output, encase it in a text block.
+ if (!$requireAgent && $requiresUnlocked)
{
- print "\n";
+ print '-- --- --'."\n";
}
+ # Wait for children (ie. gpg) to exit, to avoid defunct processes
+ waitpid($pid,0);
- if(not $content)
+ # Return whatever output we got from gpg
+ return $output;
+}
+
+# Purpose: Decrypt a string with gpg and return it
+# Usage: gpgDecryptString(STRING, BASE64=false)
+# STRING is the string to encrypt
+# BASE64 bool, if true the returned decrypted data will be base64-deencoded
+# before being returned
+sub gpgDecryptString
+{
+ my $string = shift;
+ my $base64 = shift;
+ # Decode the base64 string if needed
+ if ($base64)
{
- pDie('No data returned from gpg, giving up'."\n");
+ try
+ {
+ $string = decode_base64($string);
+ }
+ catch
+ {
+ pDie('Failed to decode BASE64: '.$_."\n");
+ }
}
- close($in) or pDie('Failed to close filehandle to gpg: '.$!."\n");
+ # Decrypt the string
+ my $output = gpgIO($string,[ qw(--decrypt) ],true);
- return $content;
+ # Return the decrypted data
+ return $output;
}
-# Purpose: Write a string to a file, encrypting it using gpg
-# Usage: gpgOut(PATH,DATA);
-# PATH is the path to the file to write the data to
-# DATA is the data to write to PATH
-sub gpgOut
+# Purpose: Encrypt a string with gpg and return it
+# Usage: gpgEncryptString(STRING, BASE64=false)
+# STRING is the string to encrypt
+# BASE64 bool, if true the returned encrypted data will be base64-encoded
+# before being returned
+sub gpgEncryptString
{
- my $path = shift;
- my $data = shift;
- my $unlinkBackup = 1;
+ my $string = shift;
+ my $base64 = shift;
- if (-e $path)
+ # Encrypt the data
+ my $output = gpgIO($string,[ qw(--encrypt) ]);
+ # Return base64 encoded data if needed
+ if ($base64)
{
- move($path,$path.'~') or pDie('Failed to create backup file: '.$!."\n".'Refusing to write data to avoid loss of previous data'."\n");
+ return encode_base64($output,'');
}
- printv(V_LOG,'Writing data using: '.join(' ',@gpg).' --encrypt --output '.$path."\n");
- open(my $out,'|-',@gpg,'--encrypt','--output',$path) or pDie('Failed to open up communication with gpg: '.$!."\n");
- print {$out} $data or pDie('Failed to write data to gpg for output: '.$!."\n");
- close($out) or do {
- warn('Failed to close communication with gpg: '.$!."\n");
- warn('Will not delete backup file in case of corruption.'."\n");
- $unlinkBackup = 0;
- };
- chmod(0600,$path);
+ # Return raw data
+ return $output;
+}
- if ($unlinkBackup)
+# Purpose: Verify metadata signature
+# Usage: verifyMetadataSignature(PATH,Data)
+sub verifyMetadataSignature
+{
+ my $path = shift;
+ my $data = shift;
+
+ printv(V_LOG,'Verifying metadata signature'."\n");
+
+ # Reset LC_ALL to C
+ my $LC_ALL = $ENV{LC_ALL};
+ $ENV{LC_ALL} = 'C';
+
+ # Retrieve which pubkey the database is encrypted with
+ my $pubkey = gpgIO(undef,[ qw(--verbose --decrypt --list-only), $path ],false,true);
+ # If we didn't get a list then something went wrong
+ if (!defined($pubkey))
{
- if ($forceUnsafe)
- {
- warn('--force-unsafe in effect: will not delete backup file in case of corruption'."\n".
- 'Note: Backup files are overwritten each time gpgpwd makes changes, so to avoid'."\n".
- 'losing it, you should copy it somewhere safe. It is at '.$path.'~'."\n");
- }
- else
+ pDie('Signature validation failed: unable to retrieve encryption pubkey from database file'."\n");
+ }
+ chomp($pubkey);
+ # Parse out the public key
+ $pubkey =~ s/gpg: public key is\s+//;
+ # If there are spaces in it then the regex above appears to have failed and we're
+ # left with an unusable string
+ if ($pubkey =~ /\s/)
+ {
+ printv(V_DEBUG,'Public key parsed to be "'.$pubkey.'"'."\n");
+ pDie('Signature validation failed: unable to parse out the pubkey from gpg output'."\n");
+ }
+
+ # Verify the signature
+ my $signature = gpgIO($data,[ qw(--verify) ],false,true);
+
+ # If no data was returned then something went wrong with gpg
+ if (!defined($signature))
+ {
+ pDie('Signature validation failed: gpg did not return any response about signature validity'."\n");
+ }
+
+ # If it does not contain this text, then the validation failed
+ if ($signature !~ /^gpg: Good signature from/m)
+ {
+ pDie('Signature validation failed: invalid signature'."\n");
+ }
+
+ printv(V_DEBUG,'gpg signature validation step succeeded'."\n");
+
+ # Parse out the signature key
+ my $sigKey = $signature;
+ $sigKey =~ s/.*gpg: Signature made .*? using \S+ key ID (\S+).*/$1/s;
+ # If there are still spaces left then we failed to parse the key
+ if ($sigKey =~ /\s/)
+ {
+ pDie('Signature validation failed: unable to parse out the sigkey from gpg output'."\n");
+ }
+
+ printv(V_DEBUG,'signing key is '.$sigKey.', pubkey for encryption is '.$pubkey."\n");
+
+ # Retrieve a list of all signatures matching the pubkey
+ my $keyList = gpgIO(undef, [ qw(--with-colons --list-sigs), $pubkey ]);
+ # If no data was returned then something went wrong with gpg
+ if(!defined($keyList))
+ {
+ pDie('Signature validation failed: Unable to retrieve signature list from gpg'."\n");
+ }
+ my $valid = false;
+ my $sigKeyLen = length($sigKey);
+ # Iterate through all lines returned from gpg
+ foreach my $line(split("\n",$keyList))
+ {
+ # The content is :-separated, split it into an array
+ my @content = split(':',$line);
+ # We only care about 'sig' lines
+ if ($content[0] eq 'sig')
{
- unlink($path.'~');
+ # If the line contains the key, and the key is at the end of the line
+ # (which is determined through the length of the line minus the length of the
+ # key, index() will return at which point $sigKeyLen starts) then it is the
+ # same key, and the validation has succeeded.
+ if (index($content[4],$sigKey) == ( length($content[4]) - $sigKeyLen ) )
+ {
+ $valid = true;
+ last;
+ }
}
}
+
+ # Error out if we have no valid signature
+ if (!$valid)
+ {
+ pDie('Signature validation failed: Signature does not match encryption key'."\n");
+ }
+
+ # Switch LC_ALL back to the user value
+ $ENV{LC_ALL} = $LC_ALL;
}
-# Purpose: Load the password database
-# Usage: $data = writeData(PATH);
-# PATH is the path to the location of the database
-#
-# If PATH does not exist then it will return an empty (but usable)
-# $data ref.
-sub loadData
+# Purpose: Retrieve the JSON structure stored in the password database
+# Usage: ($preV2,$structure) = loadJSONStructure(PATH);
+# NOTE: In practically all circumstances you want to call loadData() and NOT
+# loadJSONStructure. loadData performs validation, upgrades, safety checks
+# and so on which this function does not.
+sub loadJSONStructure
{
my $path = shift;
if (!-e $path)
{
- return {
+ return (0, {
gpgpwdDataVersion => $dataVersion,
pwds => {},
- };
+ });
+ }
+
+ my $preV2 = true;
+ my $string = gpgIO(undef,[ '--decrypt',$path ]);
+ if (!defined($string))
+ {
+ pDie('Decryption failed: GPG did not return any data'."\n");
+ }
+
+ # If we are using v2+ data format, then we need to perform additional parsing
+ if (index($string,'-----BEGIN PGP SIGNED MESSAGE-----') == 0)
+ {
+ # We're not using a pre V2 data format
+ $preV2 = false;
+ # The JSON string will be stored here
+ my $jsonString = '';
+ my $seenStart = false;
+ # Iterate through all lines to parse out the JSON string
+ foreach my $l (split("\n",$string))
+ {
+ # A line starting with { indicates the beginning of the JSON string
+ if (!$seenStart && (index($l,'{') == 0))
+ {
+ $seenStart = true;
+ }
+ # The PGP SIGNATURE line indicates the end of the JSON string
+ elsif (index($l,'-----BEGIN PGP SIGNATURE-----') == 0)
+ {
+ last;
+ }
+ # If we have seen the start (ie. the initial {), treat this as a JSON string
+ if ($seenStart)
+ {
+ $jsonString .= $l;
+ }
+ }
+ # Verify the GPG signature of the metadata
+ verifyMetadataSignature($path,$string);
+ # Replace the raw string with the parsed JSON string
+ $string = $jsonString;
}
- my $string = gpgIn($path);
my $data;
+ # Decode the JSON
try
{
$data = decode_json($string);
@@ -267,6 +637,153 @@
'If the file is a corrupt gpgpwd file, you may be able to recover it by'."\n".
'manually decrypting the file and then editing it.'."\n");
};
+ return ($preV2,$data);
+}
+
+# Purpose: Upgrade a v1 database file to v2
+# Usage: upgradeDataV1toV2($path,$data,$preV2);
+# $path is the path to the data file
+# $data is the data structure itself
+# $preV2 is the preV2 boolean as returned by loadJSONStructure
+sub upgradeDataV1toV2
+{
+ my $path = shift;
+ my $data = shift;
+ my $preV2 = shift;
+ # If we have seen V2+ data, but the file claims to be version 1 then
+ # something is corrupt, so we abort to avoid performing multiple
+ # conversions.
+ if ($preV2 == 0)
+ {
+ pDie('Upgrade triggered on V2-labelled file. The password database may be corrupt.'."\n");
+ }
+ if ($enableGit)
+ {
+ print "Old data format detected, performing a git pull...\n";
+ git('pull',$storagePath);
+ print "Reloading data from git...\n";
+ ($preV2,$data) = loadJSONStructure($path);
+ if (!$preV2)
+ {
+ print "Data in git has been upgraded, continuing...\n";
+ return loadData($path);
+ }
+ }
+ # Clear the screen, if possible, to draw attention to the upgrade message
+ if(InPath('clear'))
+ {
+ system('clear');
+ }
+ # Loudly scream about the impending data upgrade
+ print "********************* DATA UPGRADE *********************\n";
+ print "* YOU SHOULD ONLY EVER SEE THIS MESSAGE ONCE. *\n";
+ print "* IF YOU HAVE SEEN IT BEFORE, ABORT NOW WITH CONTROL+C *\n";
+ print "********************************************************\n";
+ print "\n";
+ print "About to upgrade the data format to version $dataVersion.\n";
+ print "If this has happened before someone has modified your database\n";
+ print "and the integrity of your password list may have been compromised.\n";
+ print "\n";
+ # Don't perform upgrades when --force-unsafe was passed
+ if ($forceUnsafe)
+ {
+ pDie('Refusing to upgrade when --force-unsafe is in effect.'."\n");
+ }
+ # Sleep for 20 seconds before starting the upgrade
+ print 'Conversion starting in ';
+ for(my $r = 20; $r > 0; $r--)
+ {
+ print $r;
+ sleep(1);
+ if(length($r) > 1)
+ {
+ print "\b \b";
+ }
+ print "\b";
+ }
+ print "\r \r";
+ print 'Converting ...';
+ # Perform data conversion. Encrypts every password explicitly.
+ foreach my $pwd (keys %{$data->{pwds}})
+ {
+ $data->{pwds}->{$pwd} = gpgEncryptString($data->{pwds}->{$pwd},true);
+ }
+ print "done\n";
+ # Write out the data file with the converted data
+ print 'Writing updated data...';
+ # First, make a backup
+ if (-e $storagePath.'.gpgpwdupgrade')
+ {
+ pDie($storagePath.'.gpgpwdupgrade already exists. Move this file out of the way.'."\n");
+ }
+ copy($storagePath,$storagePath.'.gpgpwdupgrade') or pDie('Error: Unable to create backup file - '.$!."\n".'Upgrade aborted.'."\n");
+ # Write the data
+ writeData($storagePath,$data);
+ print "done\nVerifying integrity...";
+ try
+ {
+ # Check that the new data is larger than the old
+ if (-s $storagePath <= -s $storagePath.'.gpgpwdupgrade')
+ {
+ die('The old data file is larger than the new. This should not be possible.');
+ }
+ # Make pDie call die() directly instead of warn()+pExit()
+ $pDieUsepExit = 0;
+ # Temporarily reduce verbosity to avoid loadData() outputting
+ # status information
+ my $origVerbosity = $verbosity;
+ $verbosity = -1;
+ # Load the data, storing errors in $error
+ my $error;
+ try
+ {
+ loadData($storagePath);
+ }
+ catch
+ {
+ $error = $_;
+ };
+ # Reset pDie to its default mode
+ $pDieUsepExit = 1;
+ # Reset verbosity to its default or user-provided value
+ $verbosity = $origVerbosity;
+ # If the loadData failed, we error out
+ if (defined $error)
+ {
+ die('Failed to load the upgraded file: '.$error);
+ }
+ }
+ catch
+ {
+ # Restore the backup file
+ print "failed\n";
+ unlink($storagePath);
+ move($storagePath.'.gpgpwdupgrade',$storagePath);
+ # Push the restored file if needed
+ git('push',$path);
+ # Make sure pDie is set to its default mode
+ $pDieUsepExit = 1;
+ # Error out
+ pDie('Error: '.$_.
+ 'Restoring the old file and aborting the upgrade to avoid corruption.'."\n".
+ 'This is likely a bug in gpgpwd.'."\n"
+ );
+ };
+ unlink($storagePath.'.gpgpwdupgrade');
+ print "done - upgrade successfully completed\n\n";
+}
+
+# Purpose: Load the password database
+# Usage: $data = loadData(PATH);
+# PATH is the path to the location of the database
+#
+# If PATH does not exist then it will return an empty (but usable)
+# $data ref.
+sub loadData
+{
+ my $path = shift;
+ statusOut('(loading password database)');
+ my($preV2,$data) = loadJSONStructure($path);
my $forceUnsafeMessage = "\n".'Ignoring this error may lead to data loss and corruption.'."\n".
'If you are sure that\'s what you want you may use --force-unsafe.'."\n";
@@ -295,6 +812,15 @@
pDie($path.': does not specify data format version - refusing to continue'."\n".$forceUnsafeMessage);
}
}
+ elsif($data->{gpgpwdDataVersion} eq '1')
+ {
+ upgradeDataV1toV2($path,$data,$preV2);
+ }
+ elsif($data->{gpgpwdDataVersion} ne '1' && $preV2)
+ {
+ # A data format v1 file is claiming to be a version 2 file. Abort.
+ pDie('pre-2 dataformat claiming to be 2+. Someone has modified your password file. Aborting.'."\n");
+ }
elsif ($data->{gpgpwdDataVersion} ne $dataVersion)
{
if ($forceUnsafe)
@@ -323,7 +849,7 @@
{
my $path = shift;
my $content = shift;
-
+
my $encoded;
$content->{generator} = 'gpgpwd '.$VERSION.' - http://random.zerodogg.org/gpgpwd';
@@ -339,6 +865,12 @@
pDie('Failed to encode data for JSON output. This is a bug!'."\n".
'JSON error: '.$_."\n");
};
+ $encoded =~ s/,/,\n/g;
+ my @encodedSplit = split("\n",$encoded);
+
+ # Sign the JSON string. The comment contains quick instructions on decrypting
+ # the embedded passwords.
+ $encoded = gpgIO(\@encodedSplit,[ qw(--clearsign --comment),'gpgpwd password file. Each password is gpg encrypted, so you will need to decrypt each password you want to look up manually: echo PASSWORD-STRING|gpg -d' ], 1);
if ($forceUnsafe)
{
@@ -354,7 +886,37 @@
print 'writing file...'."\n\n";
}
- gpgOut($path,$encoded);
+ # Make a backup of the current file
+ if (-e $path)
+ {
+ move($path,$path.'~') or pDie('Failed to create backup file: '.$!."\n".'Refusing to write data to avoid loss of previous data'."\n");
+ }
+
+ # Write the JSON string encrypted to the supplied file
+ gpgIO($encoded,[ '--encrypt','--output',$path ]);
+ chmod(0600,$path);
+
+ # Perform some paranoid sanity checks. Verify that the file we just wrote
+ # is actually there and that it is above the minimum size of 1468 bytes.
+ if (!-e $path)
+ {
+ pDie('Fatal error: "'.$path.'" did not exist after writing the file'."\n");
+ }
+ elsif(-s $path < 1468)
+ {
+ pDie('Fatal error: the size of "'.$path.'" is too small'."\n");
+ }
+
+ if ($forceUnsafe)
+ {
+ warn('--force-unsafe in effect: will not delete backup file in case of corruption'."\n".
+ 'Note: Backup files are overwritten each time gpgpwd makes changes, so to avoid'."\n".
+ 'losing it, you should copy it somewhere safe. It is at '.$path.'~'."\n");
+ }
+ else
+ {
+ unlink($path.'~');
+ }
git('push',$path);
}
@@ -383,7 +945,7 @@
my $name = $_;
my $pwd = $_;
-
+
$name =~ s/^(\S+)\s+.*/$1/;
$pwd =~ s/^\S+\s+//;
@@ -391,17 +953,38 @@
{
pDie('Failed to parse line '.$line.' in '.$file."\n");
}
- if ($data->{pwds}->{$name} && $data->{pwds}->{$name} ne $pwd)
+ if(defined $data->{pwds}->{$name})
{
- print 'Changed '.$name.' from '.$data->{pwds}->{$name}.' to '.$pwd."\n";
+ my $decryptedPW = gpgDecryptString($data->{pwds}->{$name},true);
+ if ($decryptedPW ne $pwd)
+ {
+ print 'Changed '.$name.' from '.$decryptedPW.' to '.$pwd."\n";
+ }
}
$read++;
- $data->{pwds}->{$name} = $pwd;
+ $data->{pwds}->{$name} = gpgEncryptString($pwd,true);
}
close($in);
print 'Read '.$read.' entries from '.$file."\n";
}
+# Purpose: Retrieve a simplistic "file ID" string for a file
+# Usage: id = getFileID(FILE);
+# The ID string is a very simple string combining the file size and
+# file change time.
+sub getFileID
+{
+ my $file = shift;
+ if (! -e $file || ! -r $file)
+ {
+ pDie('Unable to read '.$file."\n");
+ }
+ my $ID = -s $file;
+ $ID .= '|';
+ $ID .= (stat($file))[9];
+ return $ID;
+}
+
# Purpose: Set a password value in the database
# Usage: set($data,NAME);
# $data is the data hashref
@@ -411,26 +994,151 @@
my $data = shift;
my $name = shift;
- my $prompt = 'Password> ';
- my $random = randomPwd();
- my $copied = toClipboard($random,true);
- print 'Enter the password you want to use, or press enter to use the random'."\n";
- print 'password listed below.'."\n";
- print 'Random password: '.$random.$copied."\n";
- print 'Password> ';
- my $password = <STDIN>;
- chomp $password;
- if (!length $password)
+ my ($password, $copied);
+ my $alphaNumeric = 0;
+ my $defaultLength = 15;
+ my $length = $defaultLength;
+ my $readLine = Term::ReadLine->new('gpgpwd');
+ my $existing;
+ if(defined $data->{pwds}->{$name})
{
- $password = $random;
- print "\b\r".$prompt.$random."\n";
+ $existing = gpgDecryptString($data->{pwds}->{$name},true);
}
+ statusOut('');
- if(defined $data->{pwds}->{$name})
+ # Loop to retreive the password
+ while(1)
{
- print 'Changed '.$name.' from '.$data->{pwds}->{$name}.' to '.$password."\n";
+ # If $password is defined then this is a second-or-later iteration
+ # through the loop
+ if(defined $password)
+ {
+ print "\n";
+ }
+ my $prompt = 'Password> ';
+ # Generate a random password
+ my $random = randomPwd($length,$alphaNumeric);
+ # Copy it to the clipboard if needed
+ $copied = toClipboard($random,true);
+ if (defined $existing)
+ {
+ print 'An entry for '.$name.' already exists, with the password: '.$existing."\n";
+ print 'Enter the password you want to change it to, or press enter to use the random'."\n";
+ }
+ else
+ {
+ print 'Enter the password you want to use, or press enter to use the random'."\n";
+ }
+ print 'password listed below. Some commands are available, enter /help to list them'."\n";
+ print 'Random password: '.$random.$copied."\n";
+ $password = $readLine->readline('Password> ');
+ # If a user sends EOF, just go through the loop again
+ if (!defined $password)
+ {
+ print "\n";
+ next;
+ }
+ chomp $password;
+ if (!length $password)
+ {
+ $password = $random;
+ if (!defined $existing)
+ {
+ print "Using password: $random\n";
+ }
+ last;
+ }
+ # Output help text
+ elsif(index($password,'/help') == 0)
+ {
+ print "\n";
+ print "The following commands are available:\n";
+ printHelp('','/help','Display this help screen');
+ printHelp('','/alphanumeric','Generate an alphanumeric password (a password with only letters and numbers, without any symbols');
+ printHelp('','/regenerate','Regenerate a new password (with symbols, letters and numbers)');
+ print "Both /alphanumeric and /regenerate can take a single parameter,\n";
+ print "the length of the password to be generated. Ie. /alphanumeric 15\n";
+ print "will generate a 15-character long alphanumeric password\n";
+ }
+ # Generate a alphanumeric-only password
+ elsif(index($password,'/alphanumeric') == 0)
+ {
+ $alphaNumeric = 1;
+ }
+ # Generate a new password
+ elsif(index($password,'/regenerate') == 0)
+ {
+ $alphaNumeric = 0;
+ }
+ else
+ {
+ last;
+ }
+ # Handle a length-parameter supplied to /regenerate or /alphanumeric
+ if ($password =~ s{^/(regenerate|alphanumeric)\s+(\d+)\s*}{$2})
+ {
+ if ($password < 0 && $password > 1000)
+ {
+ print 'The password length must be higher than zero'."\n";
+ }
+ else
+ {
+ $length = $password;
+ }
+ }
+ else
+ {
+ $length = $defaultLength;
+ }
+ }
+
+ if(defined $existing)
+ {
+ print 'Changed '.$name.' from '.$existing.' to '.$password."\n";
}
- $data->{pwds}->{$name} = $password;
+ $data->{pwds}->{$name} = gpgEncryptString($password,true);
+}
+
+# Purpose: Get a fuzzy regexp for simple typos or missing characters
+sub getTypoOrMissingRegex
+{
+ my $pattern = shift;
+ my $fuzzyness = shift;
+
+ # This is done by first removing any 'non-word' character.
+ # Then each part is split into a regex that accepts the
+ # current, previous and next character in the word, as well as $fuzzyness
+ # characters after this one, which can be anything.
+ #
+ # Ie. the $name 'test', and $fuzzy=1 will become:
+ # [te][tes][est][st]
+ #
+ # Whereas $fuzzy=4 will become:
+ # [te].?.?.?[tes].?.?.?[est].?.?.?[st].?.?.?
+ my @parts = split('',$pattern);
+ $pattern = '';
+ my $prev = '';
+ my $fuzzyNo = $fuzzyness;
+ my $fuzzyString = '';
+ while($fuzzyNo--)
+ {
+ $fuzzyString .= '.?';
+ }
+ for(my $i = 0; $i < @parts; $i++)
+ {
+ my $part ='';
+ if ($i != 0)
+ {
+ $part .= $parts[$i-1];
+ }
+ $part .= $parts[$i];
+ if ( defined $parts[$i+1])
+ {
+ $part .= $parts[$i+1];
+ }
+ $pattern .= '['.$part.']'.$fuzzyString;
+ }
+ return (qr/$pattern/i,$pattern);
}
# Purpose: Get passwords from the database and output them to the user
@@ -439,17 +1147,17 @@
# NAME is the regex to search for
sub get
{
- my $data = shift;
- my $name = shift;
+ my $data = shift;
+ my $name = shift;
my $fuzzySearch = 0;
- my $best;
- my $matches;
+ my $matches = {};
- foreach my $fuzzy (0..7)
+ foreach my $fuzzy (0..10)
{
+ my $grantsStartEndBonus = true;
my $pattern = $name;
- if ($fuzzy > 0)
+ if ($fuzzy > 1)
{
$pattern =~ s/\W//g;
}
@@ -459,57 +1167,85 @@
{
$regex = qr/$name/i or pDie('Failed to parse "'.$name.'" as a perl regular expression'."\n");
}
- # Fuzzy method 1: Simple typos or missing characters
- elsif ($fuzzy < 5)
- {
- # This is done by first removing any 'non-word' character.
- # Then each part is split into a regex that accepts the
- # current, previous and next character in the word, as well as $fuzzy-1
- # characters after this one, which can be anything.
- #
- # Ie. the $name 'test', and $fuzzy=1 will become:
- # [te][tes][est][st]
- #
- # Whereas $fuzzy=4 will become:
- # [te].?.?.?[tes].?.?.?[est].?.?.?[st].?.?.?
- my @parts = split('',$pattern);
+ # Fuzzy method: Multiple words in wrong order
+ # Fuzzy method: Multiple words, where one of them is wrong
+ elsif($fuzzy == 1 || $fuzzy == 8)
+ {
+ my @parts = split(/\W/,$name);
+ next if scalar(@parts) == 1;
+ my $expr = '('.join('|',@parts).')';
$pattern = '';
- my $prev = '';
- my $fuzzyNo = $fuzzy-1;
- my $fuzzyString = '';
- while($fuzzyNo--)
+ if ($fuzzy == 1)
{
- $fuzzyString .= '.?';
- }
- for(my $i = 0; $i < @parts; $i++)
- {
- my $part ='';
- if ($i != 0)
- {
- $part .= $parts[$i-1];
- }
- $part .= $parts[$i];
- if ( defined $parts[$i+1])
+ while(defined shift(@parts))
{
- $part .= $parts[$i+1];
+ if ($pattern)
+ {
+ $pattern .= '.+';
+ }
+ $pattern .= $expr;
}
- $pattern .= '['.$part.']'.$fuzzyString;
+ }
+ elsif($fuzzy == 8)
+ {
+ $pattern = $expr;
}
$regex = qr/$pattern/i;
}
- # Fuzzy method 2: Extra characters, or other mistakes, inside the word
+ # Fuzzy method 2: Simple typos or missing characters
+ elsif ($fuzzy == 2)
+ {
+ ($regex,$pattern) = getTypoOrMissingRegex($pattern,true);
+ }
+ # Extra characters, or other mistakes, inside the word, but approx.
+ # the correct length
+ elsif($fuzzy == 3)
+ {
+ # This is based upon the assumption that the first and last
+ # characters are correct, and that all characters that we need are
+ # present. Additionally it assumes that the length is approximately
+ # correct
+ #
+ # All non-word characters are removed.
+ my @parts = split('',$pattern);
+ my $minLength = scalar(@parts)-3;
+ my $maxLength = scalar(@parts)+1;
+ $pattern = $parts[0].'['.$pattern.']{'.$minLength.','.$maxLength.'}'.$parts[-1];
+ $regex = qr/$pattern/;
+ }
+ # Simple typos or missing characters
+ elsif ($fuzzy == 4)
+ {
+ ($regex,$pattern) = getTypoOrMissingRegex($pattern,2);
+ }
+ # Simple typos or missing characters
elsif ($fuzzy == 5)
{
+ ($regex,$pattern) = getTypoOrMissingRegex($pattern,3);
+ }
+ # Simple typos or missing characters
+ elsif ($fuzzy == 6)
+ {
+ ($regex,$pattern) = getTypoOrMissingRegex($pattern,4);
+ }
+ # Extra characters, or other mistakes, inside the word
+ elsif ($fuzzy == 7 || $fuzzy == 11)
+ {
# This is based upon the assumption that the first and last characters
# are correct, and that all characters that we need are present.
#
# All non-word characters are removed.
my @parts = split('',$pattern);
$pattern = $parts[0].'['.$pattern.']+'.$parts[-1];
+ if ($fuzzy == 6)
+ {
+ $pattern = '^'.$pattern.'$';
+ $grantsStartEndBonus = false;
+ }
$regex = qr/$pattern/;
}
- # Fuzzy method 3: Correct length, incorrect order
- elsif ($fuzzy == 6)
+ # Correct length, incorrect order
+ elsif ($fuzzy == 9)
{
# This is very general, all non-word characters are removed, and
# we construct a character class consisting of all of the remaining
@@ -519,8 +1255,8 @@
$pattern = '['.$pattern.']{'.length($pattern).'}';
$regex = qr/$pattern/;
}
- # Fuzzy method 4: Acronym detection
- elsif($fuzzy == 7)
+ # Fuzzy method: Acronym detection
+ elsif($fuzzy == 10)
{
# This is about as general as it can get. We assume all characters are
# present, but that it was added as an acronym. Ie. "example site" could
@@ -532,6 +1268,7 @@
$pattern = '^['.$pattern.']+$';
$regex = qr/$pattern/i;
+ $grantsStartEndBonus = false;
}
else
{
@@ -542,7 +1279,13 @@
{
printv(V_LOG,'Trying fuzzy regex ('.$fuzzy.'): '.$pattern."\n");
}
- ($matches,$best) = getMatches($data,$regex,$name);
+
+ getMatches($matches,$data,$regex,$name,$fuzzy,$grantsStartEndBonus);
+
+ if ($allMatches)
+ {
+ next;
+ }
if(keys %{$matches})
{
@@ -553,6 +1296,7 @@
last;
}
}
+ statusOut('');
if (! (keys %{$matches}))
{
@@ -561,7 +1305,11 @@
}
print 'Passwords:';
- if ($fuzzySearch)
+ if ($allMatches)
+ {
+ print ' (showing all matches, including very fuzzy ones)';
+ }
+ elsif ($fuzzySearch)
{
print ' (found using ';
if ($fuzzySearch > 1)
@@ -572,40 +1320,91 @@
}
print "\n";
- foreach my $entry (sort keys %{$matches})
+ my $entries = scalar(keys(%{ $matches }));
+ my $entryNo = 0;
+ foreach my $entry (sort { $matches->{$b}->{score} <=> $matches->{$a}->{score} } keys %{$matches})
{
- next if $entry eq $best;
- outputEntry($entry,$matches->{$entry},0);
+ $entryNo++;
+ outputEntry($entry,$matches->{$entry}, $entryNo == $entries);
}
- outputEntry($best,$matches->{$best},true);
}
# Purpose: Get data entries that match a given regex
-# Usage: ($matches,$best) = getMatches($data,$regex,$name);
+# Usage: $matches = getMatches($data,$regex,$name);
sub getMatches
{
- my $data = shift;
- my $regex = shift;
- my $name = shift;
- my $last;
- my $best;
-
- my %matches;
+ my $matches = shift;
+ my $data = shift;
+ my $regex = shift;
+ my $name = shift;
+ my $score = shift;
+ my $grantsStartEndBonus = shift;
+ my $searchLength = length($name);
+ my $fuzzyNo = $score;
foreach my $key (sort keys %{$data->{pwds}})
{
+ next if defined $matches->{$key};
if ($key =~ $regex)
{
- $last = $key;
- $matches{$key} = $data->{pwds}->{$key};
+ my $entryScore = $score;
if ($key eq $name)
{
- $best = $key;
+ $entryScore -= 5;
}
+ else
+ {
+ # Add a 0.5 penalty for each character above the search string length
+ # IF the $name is only alphanumeric
+ if ($name !~ /\W/)
+ {
+ my $keyLen = length($key);
+ if ($keyLen > $searchLength)
+ {
+ my $penalty = ($keyLen-$searchLength)*0.25;
+ printv(V_DEBUG,'Penalizing '.$key.' '.$penalty.' due to its length'."\n");
+ $entryScore += ($keyLen-$searchLength)*0.25;
+ }
+ }
+ # Grant a bonuses if the regex matched at the beginning or end, if
+ # requested
+ if ($grantsStartEndBonus)
+ {
+ my ($bonus,$reason);
+ # Grant a -3 bonus for matching the regex at the start of
+ # the string followed by a non-word character and the fuzzy
+ # method in use is 0
+ if ($fuzzyNo == 0 && $key =~ /^$regex\S/)
+ {
+ $bonus = -3;
+ $reason = 'start-nonword';
+ }
+ # Grant a -2 bonus for matching the regex at the start of the string
+ elsif ($key =~ /^$regex/)
+ {
+ $bonus = -2;
+ $reason = 'start';
+ }
+ # Grant a -1 bonus for matching the regex at the end of the string
+ elsif($key =~ /$regex$/)
+ {
+ $bonus = -1;
+ $reason = 'end';
+ }
+ if ($reason)
+ {
+ printv(V_DEBUG,'Granting a '.$bonus.' bonus to '.$key.' due to '.$reason.' match'."\n");
+ $entryScore += $bonus;
+ }
+ }
+ }
+ $matches->{$key} = {
+ password => $data->{pwds}->{$key},
+ score => $entryScore,
+ };
}
}
- $best //= $last;
- return(\%matches,$best);
+ return $matches;
}
# Purpose: Output a password
@@ -615,16 +1414,19 @@
# COPY is a bool, if true it will toClipboard() the VALUE
sub outputEntry
{
- my $key = shift;
- my $value = shift;
- my $copy = shift;
- my $copied = '';
+ my $key = shift;
+ my $content = shift;
+ my $copy = shift;
+ my $copied = '';
+ printf('%-20s: %s',$key,"...");
+ my $value = gpgDecryptString($content->{password},true);
if ($copy)
{
$copied = toClipboard($value,true);
}
- printf('%-20s: %s'."\n",$key,$value.$copied);
+ printv(V_LOG,$key.' had a score of '.$content->{score}."\n");
+ printf("\r".'%-20s: %s'."\n",$key,$value.$copied);
}
# Purpose: Perform git actions
@@ -645,43 +1447,49 @@
}
my $cwd = getcwd;
- chdir(dirname($path));
+ chdir(dirname(realpath($path)));
- given($command)
+ if ($command eq 'safepull')
{
- when('safepull')
+ if (
+ (defined $ENV{SSH_AGENT_PID}) ||
+ (defined $ENV{GNOME_KEYRING_PID} && defined $ENV{SSH_AUTH_SOCK})
+ )
{
- if ($ENV{SSH_AGENT_PID})
- {
- $_ = 'pull';
- continue;
- }
- else
- {
- printv(V_INFO,'Not pulling since there is no SSH_AGENT_PID environment variable'."\n");
- }
+ $command = 'pull';
}
-
- when('pull')
+ else
{
- print 'Pulling git repository...'."\n";
- if (psystem('git','pull','--rebase','--quiet') != 0)
+ $command = 'noop';
+ printv(V_INFO,'Not pulling since SSH_AGENT_PID is not set, and neither is GNOME_KEYRING_PID and SSH_AUTH_SOCK'."\n");
+ }
+ }
+ if($command eq 'pull')
+ {
+ print "\n(git pulling): ";
+ if (psystem('git','pull','--rebase','--quiet') != 0)
+ {
+ if(psystem('git','pull','--quiet') != 0)
{
- if(psystem('git','pull','--quiet') != 0)
- {
- pDie('Failed to git pull, you must manually resolve the conflict'."\n");
- }
+ pDie('Failed to git pull, you must manually resolve the conflict'."\n");
}
}
-
- when('push')
+ elsif ($verbosity == 0)
{
- print 'Pushing git repository...'."\n";
- psystem('git','add',basename($path));
- psystem('git','commit','--quiet','-m','Update by gpgpwd',basename($path));
- psystem('git','push','--quiet');
+ print "\r";
}
}
+ elsif($command eq 'push')
+ {
+ print 'Pushing git repository...'."\n";
+ psystem('git','add',basename($path));
+ psystem('git','commit','--quiet','-m','Update by gpgpwd',basename($path));
+ psystem('git','push','--quiet');
+ }
+ elsif($command ne 'noop')
+ {
+ printv(V_INFO,'WARNING: Unknown command "'.$command.'" in git()'."\n");
+ }
chdir($cwd);
}
@@ -697,24 +1505,23 @@
print 'USAGE: '.basename($0).' [OPTIONS]? [COMMAND] [PARAMETERS]'."\n";
print "\n";
print "Options:\n";
- printHelp('','--help','View this help screen');
- printHelp('','--version','Display version information and exit');
- printHelp('-v','--verbose','Increase verbosity (can be supplied multiple times)');
- printHelp('-p','--password-file','Save passwords to this file instead of ~/.gpgpwddb');
- printHelp('-g','--git','Enable git mode (makes gpgpwd pull, commit and push the password file, see the manpage for details)');
- printHelp('-G','--no-git','Disable git mode (overriding --git)');
- printHelp('','--no-xclip','Disable copying of passwords to the clipboard when running under X');
- printHelp('-c','--xclip-clipboard','Use the clipboard supplied instead of the default, see the manpage for details');
- printHelp('-r','--require-agent','Require use of gpg-agent when prompting for passwords. This reduces gpg verbosity');
- printHelp('-R','--no-require-agent','Don\'t require use of gpg-agent when prompting for passwords. This will override --require-agent');
- printHelp('','--disable-agent','Disable all use of gpg-agent (implies -R)');
- printHelp('','--debuginfo','Display some information that can be useful for debugging');
+ printHelp('' , '--help' , 'View this help screen');
+ printHelp('' , '--version' , 'Display version information and exit');
+ printHelp('-v' , '--verbose' , 'Increase verbosity (can be supplied multiple times)');
+ printHelp('-p' , '--password-file' , 'Save passwords to this file instead of ~/.gpgpwddb');
+ printHelp('-g' , '--git' , 'Use git to synchronize the password file (see the manpage for details)');
+ printHelp('-G' , '--no-git' , 'Disable git mode (overriding --git)');
+ printHelp('-C' , '--no-xclip' , 'Disable copying of passwords to the clipboard when running under X');
+ printHelp('-c' , '--xclip-clipboard' , 'Use the clipboard supplied instead of the default (see the manpage for details)');
+ printHelp('' , '--all' , 'Return all possible results for "get", even very fuzzy results (default: return only the best results)');
+ printHelp('' , '--debuginfo' , 'Display some information that can be useful for debugging');
print "\n";
print "Commands:\n";
- printHelp('','get RX','Get password for RX (where RX can be a perl-compatible regular expression)');
- printHelp('','set X','Add or change password for X');
- printHelp('','remove X','Remove the entry for X');
- printHelp('','batchadd X','Batch add passwords from a file, see the manpage for the file syntax');
+ printHelp('' , 'get [name]' , 'Get password for [name] (where [name] can be a perl-compatible regular expression)');
+ printHelp('' , 'set [name]' , 'Add or change password for [name]');
+ printHelp('' , 'remove [name]' , 'Remove the entry for [name]');
+ printHelp('' , 'rename [old] [new]' , 'Rename the entry for [old] to [new]');
+ printHelp('' , 'batchadd [file]' , 'Batch add passwords from a file, see the manpage for the file syntax');
if (defined $exitValue)
{
@@ -727,57 +1534,57 @@
# Description will be reformatted to fit within a normal terminal
sub printHelp
{
- # The short option
- my $short = shift,
- # The long option
- my $long = shift;
- # The description
- my $desc = shift;
- # The generated description that will be printed in the end
- my $GeneratedDesc;
- # The current line of the description
- my $currdesc = '';
- # The maximum length any line can be
- my $maxlen = 80;
- # The length the options take up
- my $optionlen = 20;
- # Check if the short/long are LONGER than optionlen, if so, we need
- # to do some additional magic to take up only $maxlen.
- # The +1 here is because we always add a space between them, no matter what
- if ((length($short) + length($long) + 1) > $optionlen)
- {
- $optionlen = length($short) + length($long) + 1;
- }
- # Split the description into lines
- foreach my $part (split(/ /,$desc))
- {
- if(defined $GeneratedDesc)
- {
- if ((length($currdesc) + length($part) + 1 + 24) > $maxlen)
- {
- $GeneratedDesc .= "\n";
- $currdesc = '';
- }
- else
- {
- $currdesc .= ' ';
- $GeneratedDesc .= ' ';
- }
- }
- $currdesc .= $part;
- $GeneratedDesc .= $part;
- }
- # Something went wrong
- die('Option mismatch') if not $GeneratedDesc;
- # Print it all
- foreach my $description (split(/\n/,$GeneratedDesc))
- {
- printf "%-4s %-19s %s\n", $short,$long,$description;
- # Set short and long to '' to ensure we don't print the options twice
- $short = '';$long = '';
- }
- # Succeed
- return true;
+ # The short option
+ my $short = shift,
+ # The long option
+ my $long = shift;
+ # The description
+ my $desc = shift;
+ # The generated description that will be printed in the end
+ my $GeneratedDesc;
+ # The current line of the description
+ my $currdesc = '';
+ # The maximum length any line can be
+ my $maxlen = 80;
+ # The length the options take up
+ my $optionlen = 20;
+ # Check if the short/long are LONGER than optionlen, if so, we need
+ # to do some additional magic to take up only $maxlen.
+ # The +1 here is because we always add a space between them, no matter what
+ if ((length($short) + length($long) + 1) > $optionlen)
+ {
+ $optionlen = length($short) + length($long) + 1;
+ }
+ # Split the description into lines
+ foreach my $part (split(' ',$desc))
+ {
+ if(defined $GeneratedDesc)
+ {
+ if ((length($currdesc) + length($part) + 1 + 24) > $maxlen)
+ {
+ $GeneratedDesc .= "\n";
+ $currdesc = '';
+ }
+ else
+ {
+ $currdesc .= ' ';
+ $GeneratedDesc .= ' ';
+ }
+ }
+ $currdesc .= $part;
+ $GeneratedDesc .= $part;
+ }
+ # Something went wrong
+ pDie('Option mismatch') if not $GeneratedDesc;
+ # Print it all
+ foreach my $description (split("\n",$GeneratedDesc))
+ {
+ printf "%-4s %-19s %s\n", $short,$long,$description;
+ # Set short and long to '' to ensure we don't print the options twice
+ $short = '';$long = '';
+ }
+ # Succeed
+ return true;
}
# Purpose: Get the version of a shell utility
@@ -815,18 +1622,27 @@
# Usage: debugInfo();
sub debugInfo
{
+ autoInitGPGAgent();
+
print "gpgpwd version $VERSION\n";
- print "\n";
- my $pattern = "%-28s: %s\n";
- printf($pattern,'Data file',$storagePath);
- printf($pattern, 'Perl version', sprintf('%vd',$^V));
- printf($pattern, 'gpg version', getVersionFrom('gpg','--version'));
- printf($pattern, 'gpg2 version', getVersionFrom('gpg2','--version'));
- printf($pattern, 'xclip version',getVersionFrom('xclip','-version'));
- printf($pattern,'git version',getVersionFrom('git','--version'));
+ print "\n";
+ my $pattern = "%-28s: %s\n";
+ printf($pattern , 'Data file' , $storagePath);
+ printf($pattern , 'Data version' , $dataVersion);
+ printf($pattern , 'Perl version' , sprintf('%vd', $^V));
+ my $gpgV = getVersionFrom('gpg' , '--version');
+ if (InPath('gpg2') && -l InPath('gpg') && basename(readlink(InPath('gpg'))) eq 'gpg2')
+ {
+ $gpgV = '(symlinked to gpg2)';
+ }
+ printf($pattern , 'gpg version' , $gpgV);
+ printf($pattern , 'gpg2 version' , getVersionFrom('gpg2' , '--version'));
+ printf($pattern , 'gpg-agent version' , getVersionFrom('gpg-agent' , '--version'));
+ printf($pattern , 'xclip version' , getVersionFrom('xclip' , '-version'));
+ printf($pattern , 'git version' , getVersionFrom('git' , '--version'));
my $flags = '';
- foreach my $flag (qw(enableGit forceUnsafe requireAgent useGpgAgent useXclip))
+ foreach my $flag (qw(enableGit forceUnsafe requireAgent useXclip allMatches))
{
if (! eval('$'.$flag))
{
@@ -834,6 +1650,18 @@
}
$flags .= $flag.' ';
}
+ if ($gpg[0] eq 'gpg2')
+ {
+ $flags .= 'useGPGv2';
+ }
+ else
+ {
+ $flags .= 'useGPGv1';
+ }
+ if ($autoGPGAgent > -1)
+ {
+ $flags .= ' autoGPGAgent';
+ }
printf($pattern,'flags',$flags);
eval('use Digest::MD5;');
@@ -856,15 +1684,10 @@
# Usage: main()
sub main
{
- $| = 1;
+ $| = true;
- my $noRequireAgent = false;
- my $noGit = false;
-
- if (!InPath('gpg') && !InPath('gpg2'))
- {
- pDie('Failed to locate gpg which is required for gpgpwd to work, unable to continue'."\n");
- }
+ my $noGit = false;
+ my $debugInfo = false;
if (@ARGV == 0)
{
@@ -886,63 +1709,77 @@
'force-unsafe' => sub
{
warn('WARNING: Running in --force-unsafe mode. Data corruption may occur!'."\n");
- $forceUnsafe = 1;
+ $forceUnsafe = true;
},
'p|password-file=s' => \$storagePath,
- 'g|git' => \$enableGit,
+ 'g|git|i|fast-git' => \$enableGit,
'G|no-git' => \$noGit,
- 'debuginfo' => \&debugInfo,
- 'no-xclip' => sub { $useXclip = false; },
+ 'debuginfo' => \$debugInfo,
+ 'C|no-xclip' => sub { $useXclip = false; },
'c|xclip-clipboard=s' => sub
{
shift;
my $value = shift;
- given($value)
+ if ($value eq 'both')
{
- when('both')
- {
- @clipboardTargets = qw(primary clipboard);
- }
- when('clipboard')
- {
- @clipboardTargets = qw(clipboard);
- }
- when('selection')
- {
- @clipboardTargets = qw(primary);
- }
- default
- {
- pDie('Unknown value for --xclip-cilpboard: '.$value."\n");
- }
+ @clipboardTargets = qw(primary clipboard);
+ }
+ elsif($value eq 'clipboard')
+ {
+ @clipboardTargets = qw(clipboard);
+ }
+ elsif($value eq 'selection')
+ {
+ @clipboardTargets = qw(primary);
+ }
+ else
+ {
+ pDie('Unknown value for --xclip-cilpboard: '.$value."\n");
}
},
- 'r|require-agent' => \$requireAgent,
- 'R|no-require-agent' => \$noRequireAgent,
+ 'r|require-agent' => sub
+ {
+ print 'Note: --require-agent is a no-op as of version 0.4'."\n";
+ },
+ 'R|no-require-agent' => sub
+ {
+ print 'Note: --no-require-agent is a no-op as of version 0.4'."\n";
+ },
+ 'T|t|try-require-agent' => sub
+ {
+ print 'Note: --try-require-agent is the default as of version 0.4'."\n";
+ },
'disable-agent' => sub {
- $useGpgAgent = false;
+ print 'Note: --disable-agent is a no-op as of version 0.4. The agent is required.'."\n";
},
+ 'all' => \$allMatches,
) or pDie('See --help for more information'."\n");
my $command = shift(@ARGV);
- if (!$command)
+ if (!$command && !$debugInfo)
{
usage(0);
}
- if (!InPath('gpg'))
+ if (!InPath('gpg') && !InPath('gpg2'))
+ {
+ pDie('Failed to locate gpg which is required for gpgpwd to work, unable to continue'."\n");
+ }
+ elsif (
+ # If we don't have gpg(1) then we need to use gpg2
+ !InPath('gpg')
+ # If we have both gpg(1) and gpg2, check if gpg is a symlink to gpg2,
+ # if it is then enable gpg2-mode
+ || (InPath('gpg2') && -l InPath('gpg') && basename(readlink(InPath('gpg'))) eq 'gpg2')
+ )
{
# Note: If gpg isn't installed then we *know* gpg2 is installed,
# because if neither is installed we refuse to run at all (test at the
# top of main() )
printv(V_INFO,'Using gpg2 instead of gpg'."\n");
$gpg[0] = 'gpg2';
- if (! $useGpgAgent)
- {
- pDie('You only have gpg2 installed (not gpg). --disable-agent does not work with gpg2'."\n");
- }
}
# Verify that we're able to read and write to $storagePath, as well as
@@ -967,121 +1804,165 @@
}
}
- # Unset requireAgent if --no-require-agent or --disable-agent was specified.
- if ($noRequireAgent || !$useGpgAgent)
- {
- $requireAgent = false;
- }
- # Add --no-tty to gpg's parameter list to reduce its output and
- # require a gpg-agent.
- elsif($requireAgent)
+ if(gpgAgentRunning)
{
+ $requireAgent = true;
+ # Add --no-tty to gpg's parameter list to reduce its output and
+ # require a gpg-agent.
push(@gpg,'--no-tty');
- }
- if ($useGpgAgent)
- {
- push(@gpg,'--use-agent');
- }
- else
- {
- $requireAgent = false;
- push(@gpg,'--no-use-agent');
+ # Explicitly enable the agent for gpg1
+ if ($gpg[0] ne 'gpg2')
+ {
+ push(@gpg,'--use-agent');
+ }
}
# Disable --git if --no-git was supplied
if ($noGit)
{
- $enableGit = false;
+ $enableGit = false;
}
- given($command)
+ if ($debugInfo)
+ {
+ debugInfo();
+ }
+ elsif ($command eq 'get')
{
- when('get')
+ if(! @ARGV)
{
- if(! @ARGV)
- {
- warn('Missing parameter to get: what to retrieve'."\n");
- usage(103);
- }
- elsif(@ARGV != 1)
- {
- pDie('Too many parameters for "get"'."\n");
- }
-
- git('safepull',$storagePath);
- my $data = loadData($storagePath);
- get($data,@ARGV);
+ warn('Missing parameter to get: what to retrieve'."\n");
+ usage(103);
}
-
- when('batchadd')
+ elsif(@ARGV != 1)
{
- if (!@ARGV)
- {
- warn('Missing parameter to batchadd: path to the file to read'."\n");
- usage(104);
- }
- elsif(@ARGV != 1)
- {
- pDie('Too many parameters for "batchadd"'."\n");
- }
- git('pull',$storagePath);
- my $data = loadData($storagePath);
- loadFromFile(shift(@ARGV),$data);
- writeData($storagePath,$data);
+ pDie('Too many parameters for "get"'."\n");
}
- when('remove')
- {
- if (!@ARGV)
- {
- warn('Missing parameter to remove: what to remove'."\n");
- usage(104);
- }
- elsif(@ARGV != 1)
- {
- pDie('Too many parameters for "remove"'."\n");
- }
- my $name = shift(@ARGV);
+ my $data = loadData($storagePath);
+ get($data,@ARGV);
- git('pull',$storagePath);
- my $data = loadData($storagePath);
- if ($data->{pwds}->{$name})
- {
- print 'Removed '.$name.' (with the password '.$data->{pwds}->{$name}.')'."\n";
- delete($data->{pwds}->{$name});
- }
- else
+ if ($enableGit)
+ {
+ my $fileID = getFileID($storagePath);
+ git('safepull',$storagePath);
+ # If the file has changed due to the pull, then we re-fetch
+ if ($fileID ne getFileID($storagePath))
{
- print 'No entry named '.$name.' found. Doing nothing.'."\n";
- pExit(0);
+ print "\n";
+ print "File updated by git, re-reading passwords:\n";
+ my $data = loadData($storagePath);
+ get($data,@ARGV);
}
- writeData($storagePath,$data);
}
+ }
+ elsif($command eq 'batchadd')
+ {
+ if (!@ARGV)
+ {
+ warn('Missing parameter to batchadd: path to the file to read'."\n");
+ usage(104);
+ }
+ elsif(@ARGV != 1)
+ {
+ pDie('Too many parameters for "batchadd"'."\n");
+ }
+ git('pull',$storagePath);
+ my $data = loadData($storagePath);
+ statusOut('');
+ loadFromFile(shift(@ARGV),$data);
+ writeData($storagePath,$data);
+ }
+ elsif($command eq 'remove')
+ {
+ if (!@ARGV)
+ {
+ warn('Missing parameter to remove: what to remove'."\n");
+ usage(104);
+ }
+ elsif(@ARGV != 1)
+ {
+ pDie('Too many parameters for "remove"'."\n");
+ }
+ my $name = shift(@ARGV);
- when('add')
+ git('pull',$storagePath);
+ my $data = loadData($storagePath);
+ statusOut('');
+ if ($data->{pwds}->{$name})
+ {
+ print 'Removed '.$name.' (with the password '.gpgDecryptString($data->{pwds}->{$name},true).')'."\n";
+ delete($data->{pwds}->{$name});
+ }
+ else
+ {
+ print 'No entry named '.$name.' found. Doing nothing.'."\n";
+ pExit(0);
+ }
+ writeData($storagePath,$data);
+ }
+ elsif($command eq 'set' || $command eq 'add' || $command eq 'change')
+ {
+ if (!@ARGV)
{
- $_ = 'set';
- continue;
+ warn('Missing parameter to '.$command.': what to set'."\n");
+ usage(104);
}
+ elsif(@ARGV != 1)
+ {
+ pDie('Too many parameters for "'.$command.'" (note that you will be prompted for a password)'."\n");
+ }
+ git('pull',$storagePath);
+ my $data = loadData($storagePath);
+ set($data,@ARGV);
+ writeData($storagePath,$data);
+ }
+ elsif($command eq 'rename')
+ {
+ my $old = shift(@ARGV);
+ my $new = shift(@ARGV);
+ if (!defined $old)
+ {
+ warn('Missing parameters to rename: old name, new name'."\n");
+ usage(104);
+ }
+ elsif (!defined $new)
+ {
+ warn('Missing parameters to rename: new name'."\n");
+ usage(104);
+ }
+ elsif(@ARGV != 0)
+ {
+ pDie('Too many parameters for "rename"'."\n");
+ }
+ git('pull',$storagePath);
+ my $data = loadData($storagePath);
+ statusOut('');
- when('set')
+ my $entry = $data->{pwds}->{$old};
+ if (!defined $entry)
{
- if (!@ARGV)
- {
- warn('Missing parameter to set: what to set'."\n");
- usage(104);
- }
- elsif(@ARGV != 1)
- {
- pDie('Too many parameters for "set" (note that you will be prompted for a password)'."\n");
- }
- git('pull',$storagePath);
- my $data = loadData($storagePath);
- set($data,@ARGV);
- writeData($storagePath,$data);
+ pDie('Failed to find "'.$old.'". Note that you must specify the exact name when'."\n".'using "rename", as it does no fuzzy searching'."\n");
}
+ $data->{pwds}->{$new} = $entry;
+ delete($data->{pwds}->{$old});
+
+ print 'Renamed the entry for '.$old.' to '.$new."\n";
- default
+ writeData($storagePath,$data);
+ }
+ else
+ {
+ if (@ARGV == 0)
+ {
+ print "$command is an unknown command. Maybe you meant:\n";
+ print basename($0).' get '.$command."\n";
+ print ' or'."\n";
+ print basename($0).' set '.$command."\n";
+ print "\nSee ".basename($0)." --help for more information.\n";
+ exit(103);
+ }
+ else
{
warn "Unknown command: $command\n";
usage(102);
@@ -1142,11 +2023,19 @@
=item B<-g, --git>
-Enables git(1) mode. This causes gpgpwd to I<git pull> before it writes any
-change to the file, and I<git commit> and I<git push> after a change has been
-made (it will also pull in read mode, but only if it detects a running ssh-agent).
-This can be used to keep a password database in sync between several different
-computers.
+Enables git(1) mode. This can be used to keep a password database in sync
+between several different computers. This causes gpgpwd to I<git pull> before
+it writes any change to the file, and I<git commit> and I<git push> after a
+change has been made. In read mode it will I<git pull> after getting a
+password, if it detects that the password file has changed after pulling,
+gpgpwd will process your get request again, in case the password you wanted has
+changed.
+
+It requires you to configure a git repository that contains your password file,
+and a central server that gpgpwd can push to and pull from. You will need to
+initialize and set up the git repository yourself. You will either need to use
+I<--password-file> to provide the path to the file in your git repository, or
+symlink I<~/.gpgpwddb> to the password file located in your git repository.
=item B<-G, --no-git>
@@ -1155,7 +2044,7 @@
the default gpgpwd parameters, but you want to operate on a different
--password-file that is not in git.
-=item B<--no-xclip>
+=item B<-C, --no-xclip>
Disables use of xclip(1). By default gpgpwd will copy passwords to the clipboard
for easy pasting into password fields. When this option is supplied it supresses
@@ -1183,30 +2072,16 @@
=back
-=item B<-r, --require-agent>
-
-Require use of gpg-agent when prompting for passwords. This allows gpgpwd to
-reduce the verbosity of gpg, resulting in more compact output, with the caveat
-that gpg won't prompt you for your password if gpg-agent fails.
-
-=item B<-R, --no-require-agent>
+=item B<--all>
-Forces gpgpwd not to require a gpg-agent. This will override any --require-agent
-parameters that have been specified. This can be useful if you generally have
-an agent available and like the output when I<--require-agent> is in affect
-better, but sometimes have to use gpgpwd when an agent is unavailable. Ie.
-if you have a shell alias that adds --require-agent to the default gpgpwd
-parameters, you can use -R to override that.
-
-=item B<--disable-agent>
-
-Disables all use of gpg-agent. This option implies --no-require-agent.
+Return all posible results for a "get" request. This includes very fuzzy
+results. The default, which is to return only the best results, is usually
+preferable to I<--all>.
=item B<--debuginfo>
Display some information useful for debugging.
-
=back
=head1 COMMANDS
@@ -1231,6 +2106,10 @@
Remove the password for NAME from the database.
+=item B<rename> I<OLDNAME> I<NEWNAME>
+
+Rename the entry for OLDNAME to NEWNAME.
+
=item B<batchadd> I<FILE>
Read and add a list of passwords from FILE. The format is simple:
@@ -1270,6 +2149,10 @@
Get the password for testpwd, copying it to both the selection and regular
clipboards.
+=item gpgpwd rename testpwd test-password
+
+Rename 'testpwd' to 'test-password'.
+
=back
=head1 HELP/SUPPORT
@@ -1300,7 +2183,7 @@
=head1 LICENSE AND COPYRIGHT
-Copyright (C) Eskild Hustvedt 2012
+Copyright (C) Eskild Hustvedt 2012, 2013, 2014
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
|