cra pushed to kpcli (master). "Update source to 2.8"
notifications at fedoraproject.org
notifications at fedoraproject.org
Wed May 27 03:16:04 UTC 2015
From fb2dfc513f535b4995713b378170d7bbea379718 Mon Sep 17 00:00:00 2001
From: "Charles R. Anderson" <cra at wpi.edu>
Date: Tue, 26 May 2015 23:15:23 -0400
Subject: Update source to 2.8
diff --git a/kpcli-2.7.pl b/kpcli-2.7.pl
deleted file mode 100644
index 4082871..0000000
--- a/kpcli-2.7.pl
+++ /dev/null
@@ -1,4760 +0,0 @@
-#!/usr/bin/perl
-
-###########################################################################
-#
-# kpcli - KeePass Command Line Interface
-#
-# Author: Lester Hightower <hightowe at cpan dot org>
-#
-# This program was inspired by "kedpm -c" and resulted despite illness
-# (or more likely because of it) over the USA Thanksgiving holiday in
-# late November of 2010. As a long-time user of the Ked Password Manager
-# I really missed a command line interface after getting an Android cell
-# phone and switching to KeePass, so that I could access my password
-# database on my phone. This program scratches that itch.
-#
-###########################################################################
-
-# The required perl modules
-use strict; # core
-use version; # core
-use File::Spec; # core
-use FileHandle; # core
-use Getopt::Long; # core
-use File::Basename; # core
-use Digest::file; # core
-use Digest::MD5; # core
-use Digest::SHA qw(sha256); # core
-use Data::Dumper qw(Dumper); # core
-use Term::ANSIColor; # core
-use Carp qw(longmess); # core
-use English qw(-no_match_vars); # core
-use Time::HiRes qw(gettimeofday tv_interval); # core
-use Time::Local qw(timegm); # core
-use Clone qw(clone); # core
-use POSIX; # core, required for unsafe signal handling
-use Crypt::Rijndael; # non-core, libcrypt-rijndael-perl on Ubuntu
-use Sort::Naturally; # non-core, libsort-naturally-perl on Ubuntu
-use Term::ReadKey; # non-core, libterm-readkey-perl on Ubuntu
-use Term::ShellUI; # non-core, libterm-shellui-perl on Ubuntu
-use File::KeePass 0.03; # non-core, libfile-keepass-perl on Ubuntu
- # - >=v0.03 needed due critical bug fixes
-# Pull in optional perl modules with run-time loading
-my %OPTIONAL_PM=();
-# Data::Password is needed for the pwck command (check password quality).
-if (runtime_load_module(\%OPTIONAL_PM,'Data::Password',[qw(IsBadPassword)])) {
- no warnings 'once';
- $Data::Password::MINLEN = 8;
- $Data::Password::MAXLEN = 0;
-}
-# Capture::Tiny is needed to safely optionally-load Clipboard.
-# Clipboard is needed by the clipboard copy commands (xw, xu, xp, and xx).
-if (runtime_load_module(\%OPTIONAL_PM,'Capture::Tiny',[qw(capture)])) {
- # Clipboard tests its dependencies at import() and writes warnings to STDERR.
- # Tiny::Capture is used to catch those warnings and we silently hold them
- # until and unless someone tries to use dependant functions.
- my ($out, $err, @result) = capture(
- sub { runtime_load_module(\%OPTIONAL_PM,'Clipboard',undef); } );
- if (length($err)) {
- # Cleanup the error message for for better viewing by the user
- $err =~ s/^\s+//g; $err =~ s/\s+$//g; $err =~ s/^(.*)$/ > $1/mg;
- $OPTIONAL_PM{'Clipboard'}->{error} = $err;
- }
-} else {
- # If we didn't get Capture::Tiny, also mark Clipboard as not loaded.
- $OPTIONAL_PM{'Clipboard'}->{loaded} = 0;
-}
-# Win32::Console::ANSI is needed to emulate ANSI colors on Windows
-if (lc($OSNAME) =~ m/^mswin/) {
- if (! runtime_load_module(\%OPTIONAL_PM,'Win32::Console::ANSI',undef)) {
- # If we don't have Win32::Console::ANSI then we want to override
- # &main::color() and colored() from Term::ANSIColor with NOOPs.
- no strict 'refs';
- *color = sub { my $color = shift @_; return ''; };
- *colored = sub { my $color = shift @_; my $text=shift @_; return $text; };
- }
-}
-
-$|=1; # flush immediately after writes or prints to STDOUT
-
-my $DEBUG=0;
-$Data::Dumper::Useqq = 1; # Have Dumper escape special chars (like \0)
-my $DEFAULT_PASSWD_LEN = 20; # Default length of generated passwords.
-my $DEFAULT_PASSWD_MIN = 1; # Minimum length of generated passwords.
-my $DEFAULT_PASSWD_MAX = 50; # Maximum length of generated passwords.
-my $DEFAULT_ENTRY_ICON = 0; # In keepassx, icon 0 is a golden key
-my $DEfAULT_GROUP_ICON = 49; # In keepassx, icon 49 is an opened file folder
-my $DEfAULT_BAKUP_ICON = 2; # In keepassx, icon 2 is a warning sign
-my $FOUND_DIR = '_found'; # The find command's results go in /_found/
-my $MAX_ATTACH_SIZE = 2*1024**2; # Maximum size of entry file attachments
-
-# Application name and version
-my $APP_NAME = basename($0); $APP_NAME =~ s/\.(pl|exe)$//;
-my $VERSION = "2.7";
-
-our $HISTORY_FILE = ""; # Gets set in the MyGetOpts() function
-my $opts=MyGetOpts(); # Will only return with options we think we can use
-
-my $doc_passwd_gen =
- "For password generation, the \"g\" method produces a\n" .
- "string of random characters, the \"w\" method creates a\n" .
- "4-word string inspired by \"correct horse battery staple\"\n" .
- "(http://xkcd.com/936/), and the \"i\" method provides an\n" .
- "interactive user interface to the \"g\" and \"w\" methods.\n" .
- "\n" .
- "By default, the \"g\" and \"i\" methods generate passwords that\n" .
- "are $DEFAULT_PASSWD_LEN characters long. " .
- "That can be controlled by providing an\n" .
- "integer immediately after the \"g|i\" in the range of "
- . "$DEFAULT_PASSWD_MIN-$DEFAULT_PASSWD_MAX.\n" .
- "For example, \"g17\" will generate a 17 character password.\n" .
- "";
-# Setup our Term::ShellUI object
-my $term = new Term::ShellUI(
- app => $APP_NAME,
- term => get_readline_term(\%OPTIONAL_PM, $APP_NAME),
- history_file => $HISTORY_FILE,
- keep_quotes => 0,
- commands => {
- #"" => { args => sub { shift->complete_history(@_) } },
- "history" => { desc => "Prints the command history",
- doc => "\nSpecify a number to list the last N lines of history.\n" .
- "Pass -c to clear the command history.\n" .
- "Pass -d NUM to delete a single item.\n",
- args => "[-c] [-d] [number]",
- method => sub { shift->history_call(@_) },
- exclude_from_history => 1,
- },
- "ver" => {
- desc => "Print the version of this program",
- doc => "\n" .
- "Add the -v option to get an inventory of the versions\n" .
- "of the various dependencies being used. Please provide\n" .
- "that information in any bug reports filed.\n" .
- "",
- method => \&cli_version,
- minargs => 0, maxargs => 1,
- exclude_from_history => 1,
- },
- "version" => { alias => "ver",
- exclude_from_completion=>1, exclude_from_history => 1,},
- "vers" => {
- desc => "Same as \"ver -v\"",
- minargs => 0, maxargs => 0,
- method => sub { cli_version(shift, { args => ['-v'] }); },
- exclude_from_completion=>1, exclude_from_history => 1,
- },
- "versions" => { alias => "vers",
- exclude_from_completion=>1, exclude_from_history => 1,},
- "help" => {
- desc => "Print helpful information",
- args => sub { shift->help_args(undef, @_); },
- method => sub { my_help_call(@_); },
- exclude_from_history => 1,
- #method => sub { shift->help_call(undef, @_); }
- },
- "h" => { alias => "help",
- exclude_from_completion=>1, exclude_from_history => 1,},
- "?" => { alias => "help",
- exclude_from_completion=>1, exclude_from_history => 1,},
- "cl" => {
- desc => "Change directory and list entries (cd+ls)",
- doc => "\n" .
- "Change the pwd to an absolute or relative path\n" .
- "and list the entries there. This is a useful way\n" .
- "to quickly navigate to a path and have the entries\n" .
- "listed in preparation to run the show command.\n",
- maxargs => 1,
- args => \&complete_groups,
- method => sub { if(cli_cd(@_) == 0) { cli_ls() } },
- },
- "cls" => {
- desc => 'Clear screen ("clear" command also works)',
- doc => "\n" .
- "Clear the screen, which is useful when guests arrive.\n",
- maxargs => 0,
- method => \&cli_cls,
- exclude_from_history => 1,
- },
- "clear" => { alias => "cls", exclude_from_history => 1, },
- "cd" => {
- desc => "Change directory (path to a group)",
- doc => "\n" .
- "Change the pwd to an absolute or relative path.\n" .
- "Slashes in names are escaped with backslashes:\n" .
- "(i.e. \"cd /personal/Comcast\\/Xfinity\").\n",
- maxargs => 1,
- args => \&complete_groups,
- method => \&cli_cd,
- },
- "chdir" => { alias => 'cd' },
- "saveas" => {
- desc => "Save to a specific filename " .
- "(saveas <file.kdb> [<file.key>])",
- minargs => 1, maxargs => 2,
- args => [\&Term::ShellUI::complete_files,
- \&Term::ShellUI::complete_files],
- proc => sub { run_no_TSTP(\&cli_saveas, @_); },
- },
- "export" => {
- desc => "Export entries to a new KeePass DB " .
- "(export <file.kdb> [<file.key>])",
- doc => "\n" .
- "Use this command to export the full tree of groups\n" .
- "and entries to another KeePass database file on disk,\n" .
- "starting at your current path (pwd).\n" .
- "\n" .
- "This is also a \"safer\" way to change your database\n" .
- "password. Export from /, verify that the new file is\n" .
- "good, and then remove your original file.\n",
- minargs => 1, maxargs => 2,
- args => [\&Term::ShellUI::complete_files,
- \&Term::ShellUI::complete_files],
- proc => sub { run_no_TSTP(\&cli_export, @_); },
- },
- "import" => {
- desc => "Import another KeePass DB " .
- "(import <file.kdb> <path> [<file.key>])",
- doc => "\n" .
- "Use this command to import the entire KeePass DB\n" .
- "specified by <file.kdb> into a new group at <path>.\n",
- minargs => 2, maxargs => 3,
- args => [\&Term::ShellUI::complete_files,\&complete_groups,
- \&Term::ShellUI::complete_files],
- proc => sub { run_no_TSTP(\&cli_import, @_); },
- },
- "open" => {
- desc => "Open a KeePass database file " .
- "(open <file.kdb> [<file.key>])",
- minargs => 1, maxargs => 2,
- args => [\&Term::ShellUI::complete_files,
- \&Term::ShellUI::complete_files],
- proc => sub { run_no_TSTP(\&cli_open, @_); },
- },
- "mkdir" => {
- desc => "Create a new group (mkdir <group_name>)",
- minargs => 1, maxargs => 1,
- args => \&complete_groups,
- method => \&cli_mkdir,
- },
- "rmdir" => {
- desc => "Delete a group (rmdir <group_name>)",
- minargs => 1, maxargs => 1,
- args => \&complete_groups,
- method => \&cli_rmdir,
- },
- "dir" => { alias => "ls", },
- "ls" => {
- desc => "Lists items in the pwd or a specified path " .
- "(\"dir\" also works)",
- minargs => 0, maxargs => 99,
- args => \&complete_groups,
- method => \&cli_ls,
- },
- "new" => {
- desc => "Create a new entry: new <optional path&|title>",
- doc => "\n" .
- "The new command is used to create a new entry.\n" .
- "\n" .
- "Usage is straightforward.\n" .
- "\n" .
- $doc_passwd_gen .
- "",
- minargs => 0, maxargs => 1,
- args => [\&complete_groups],
- method => sub { run_no_TSTP(\&cli_new, @_); },
- },
- "rm" => {
- desc => "Remove an entry: rm <path to entry|entry number>",
- minargs => 1, maxargs => 1,
- args => \&complete_groups_and_entries,
- method => \&cli_rm,
- },
- "xu" => {
- desc => "Copy username to clipboard: xu <entry path|number>",
- minargs => 1, maxargs => 1,
- args => \&complete_groups_and_entries,
- method => sub { cli_xN('xu', @_); }
- },
- "xw" => {
- desc => "Copy URL (www) to clipboard: xw <entry path|number>",
- minargs => 1, maxargs => 1,
- args => \&complete_groups_and_entries,
- method => sub { cli_xN('xw', @_); }
- },
- "xp" => {
- desc => "Copy password to clipboard: xp <entry path|number>",
- minargs => 1, maxargs => 1,
- args => \&complete_groups_and_entries,
- method => sub { cli_xN('xp', @_); }
- },
- "xx" => {
- desc => "Clear the clipboard: xx",
- minargs => 0, maxargs => 0,
- method => sub { cli_xN('xx'); }
- },
- "pwck" => {
- desc => "Check password quality: pwck <entry|group>",
- doc => "\n" .
- "The pwck command test password quality for entries.\n" .
- "You can check an individual entry or all entries inside\n" .
- "of a group, recursively. To check every password in your\n" .
- "database, use: pwck /\n" .
- "",
- minargs => 1, maxargs => 1,
- args => \&complete_groups_and_entries,
- method => \&cli_pwck,
- },
- "stats" => {
- desc => "Prints statistics about the open KeePass file",
- method => \&cli_stats,
- },
- "show" => {
- desc => "Show an entry: show [-f] [-a] <entry path|entry number>",
- doc => "\n" .
- "The show command tries to intelligently determine\n" .
- "what you want to see and to make it easy to display.\n" .
- "Show can take a path to an entry as its argument or\n" .
- "an entry number as shown by the ls command.\n" .
- "\n" .
- "When using entry numbers, they will refer to the last\n" .
- "path when an ls was performed or pwd if ls has not\n" .
- "yet been run.\n" .
- "\n" .
- "By default, passwords are \"hidden\" by being displayed as\n" .
- "\"red on red\" where they can be copied to the clip board\n" .
- "but not seen. Provide the -f option to show passwords.\n" .
- "Use the -a option to see create and modified times, and\n" .
- "the index of the icon set for the entry.\n" .
- "",
- minargs => 1, maxargs => 3,
- args => \&complete_groups_and_entries,
- method => \&cli_show,
- },
- "edit" => {
- desc => "Edit an entry: edit <path to entry|entry number>",
- doc => "\n" .
- "The edit command is used to modify an entry.\n" .
- "\n" .
- "Usage is straightforward.\n" .
- "\n" .
- $doc_passwd_gen .
- "",
- minargs => 1, maxargs => 1,
- args => \&complete_groups_and_entries,
- method => sub { run_no_TSTP(\&cli_edit, @_); },
- },
- "attach" => {
- desc => "Manage attachments: attach <path to entry|entry number>",
- doc => "\n" .
- "The attach command provided an interactive user interface\n" .
- "for managing file attachments on an entry.\n" .
- "",
- minargs => 1, maxargs => 1,
- args => \&complete_groups_and_entries,
- method => sub { run_no_TSTP(\&cli_attach, @_); },
- },
- "mv" => {
- desc => "Move an item: mv <path to group|entry> <path to group>",
- minargs => 2, maxargs => 2,
- args => [\&complete_groups_and_entries, \&complete_groups],
- method => \&cli_mv,
- },
- "rename" => {
- desc => "Rename a group: rename <path to group>",
- minargs => 1, maxargs => 1,
- args => \&complete_groups,
- method => \&cli_rename,
- },
- "copy" => {
- desc => "Copy an entry: copy <path to entry> <path to new entry>",
- minargs => 2, maxargs => 2,
- args => [\&complete_groups_and_entries,
- \&complete_groups_and_entries],
- method => \&cli_copy,
- },
- "cp" => { alias => "copy", },
- "clone" => {
- desc =>"Clone an entry: clone <path to entry> <path to new entry>",
- doc => "\n" .
- "Clones an entry for you to edit. Similar to doing\n" .
- "\"cp foo bar; edit bar\" if that were possible.\n" .
- "\n",
- minargs => 2, maxargs => 2,
- args => [\&complete_groups_and_entries,
- \&complete_groups_and_entries],
- method => sub { run_no_TSTP(\&cli_clone, @_); },
- },
- "save" => {
- desc => "Save the database to disk",
- minargs => 0, maxargs => 0, args => "",
- method => sub { run_no_TSTP(\&cli_save, @_); },
- },
- "close" => {
- desc => "Close the currently opened database",
- minargs => 0, maxargs => 0, args => "",
- method => sub { run_no_TSTP(\&cli_close, @_); },
- },
- "find" => {
- desc => "Finds entries by Title",
- doc => "\n" .
- "Searches for entries with the given search term\n" .
- "in their title and places matches into \"/$FOUND_DIR/\".\n" .
- "\n" .
- "Add -a to search data fields beyond just the title.\n" .
- "\n" .
- "Use -expired to find expired entries.\n",
- minargs => 1, maxargs => 2, args => "<search string>",
- method => \&cli_find,
- },
- "pwd" => {
- desc => "Print the current working directory",
- maxargs => 0, proc => \&cli_pwd,
- },
- "icons" => {
- desc => "Change group or entry icons in the database",
- maxargs => 0,
- proc => sub { run_no_TSTP(\&cli_icons, @_); },
- },
- "quit" => {
- desc => "Quit this program (EOF and exit also work)",
- maxargs => 0,
- method => sub { run_no_TSTP(\&cli_quit, @_); },
- exclude_from_history => 1,
- },
- "exit" => { alias => "quit", exclude_from_history => 1,}
- },
- );
-$term->prompt(\&term_set_prompt);
-
-# Seed our state global variable
-our $state={
- 'appname' => $APP_NAME,
- 'term' => $term,
- 'OPTIONAL_PM' => \%OPTIONAL_PM,
- 'kdb_has_changed' => 0,
- 'last_ls_path' => '',
- 'put_master_passwd' => \&put_master_passwd,
- 'get_master_passwd' => \&get_master_passwd,
- };
-# If given --kdb=, open that file
-if (length($opts->{kdb})) {
- my $err = open_kdb($opts->{kdb}, $opts->{key}); # Sets $state->{'kdb'}
- if (length($err)) {
- print "Error opening file: $err\n";
- }
-} else {
- new_kdb($state);
-}
-
-# Enter the interative kpcli shell session
-print "\n" .
- "KeePass CLI ($APP_NAME) v$VERSION is ready for operation.\n" .
- "Type 'help' for a description of available commands.\n" .
- "Type 'help <command>' for details on individual commands.\n";
-if ($DEBUG) {print 'Using '.$term->{term}->ReadLine." for readline.\n"; }
-if ( (! $DEBUG) && (lc($OSNAME) !~ m/^mswin/) &&
- ($term->{term}->ReadLine ne 'Term::ReadLine::Gnu')) {
- print color('yellow') . "\n" .
- "* NOTE: You are using " . $term->{term}->ReadLine . ".\n" .
- " Term::ReadLine::Gnu will provide better functionality.\n" .
- color('clear');
-}
-# My patch made it into Term::ShellUI v0.9, but I still chose not to make
-# this program demand >=0.9 and instead look for the add_eof_exit_hook()
-# and use it if it exists and warn if not.
-if (Term::ShellUI->can('add_eof_exit_hook')) {
- $term->add_eof_exit_hook(\&eof_exit_hook);
-} else {
- warn "* Please upgrade Term::ShellUI to version 0.9 or newer.\n";
-}
-print "\n";
-
-setup_signal_handling(); # Exactly what the name indicates...
-
-$term->run();
-
-exit;
-
-############################################################################
-############################################################################
-############################################################################
-
-sub open_kdb {
- my $file=shift @_;
- my $key_file=shift @_;
- our $state;
-
- # Make sure the file exists, is readable, and is a keepass file
- if (! -f $file) {
- return "File does not exist: $file";
- }
- if (! -r $file) {
- return "File is not readable: $file";
- }
- if (magic_file_type($file) ne 'keepass') {
- return "Does not appear to be a KeePass file: $file";
- }
-
- # Look for lock file and warn if it is found
- my $lock_file = $file . '.lock'; # KeePassX style
- if (-f $lock_file &&
- ! (defined($opts->{readonly}) && int($opts->{readonly})) ) {
- print color('bold yellow') .
- "WARNING:" .
- color('clear') . color('red') .
- " A KeePassX-style lock file is in place for this file.\n" .
- " It may be opened elsewhere." .
- " " . color('bold yellow') . "Be careful of saving!\n" .
- color('clear');
- } else {
- $state->{placed_lock_file} = $lock_file;
- }
-
- # Ask the user for the master password and then open the kdb
- my $master_pass=GetMasterPasswd();
- if (recent_sigint()) { return undef; } # Bail on SIGINT
- $state->{kdb} = File::KeePass->new;
- if (! eval { $state->{kdb}->load_db($file,
- composite_master_pass($master_pass, $key_file)) }) {
- die "Couldn't load the file $file: $@";
- }
-
- if ($state->{placed_lock_file}) {
- touch_file($state->{placed_lock_file});
- }
-
- $state->{kdb_file} = $file;
- $state->{key_file} = $key_file;
- $state->{kdb_ver} = $state->{kdb}->{header}->{version}; # will be 1 or 2
- $state->{put_master_passwd}($master_pass);
- $state->{kdb_has_changed}=0;
- $master_pass="\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
-
- # Build the %all_grp_paths_fwd and %all_grp_paths_rev structures
- refresh_state_all_paths();
-
- # Store the md5sum of the file so we can watch for unexpected changes
- $state->{kdb_file_md5} = Digest::file::digest_file_hex($file, "MD5");
-
- # Initialize our state into "/"
- cli_cd($term, {'args' => ["/"]});
-
- return ''; # If we return anything else it is an error message
-}
-
-# Called by Term::ShellUI to get the user prompt
-sub term_set_prompt($$) {
- my $term=shift @_;
- my $raw_cmd=shift @_;
- our $state;
-
- my $app=$state->{appname};
- my $pwd=get_pwd();
- return "$app:$pwd> ";
-}
-
-# Walks through a tree of groups building a flat hash of NULL-separated
-# paths to group IDs. Called on the root to build a full path tree.
-sub build_all_group_paths {
- my $hash = shift @_;
- my $g = shift @_;
- my $root_path = shift @_ || [];
-
- foreach my $me (@{$g}) {
- my @path_to_me = @{$root_path};
- push @path_to_me, $me->{title};
- my $path=join("\0", at path_to_me);
- my $err_path = '/' . humanize_path($path);
- if (defined($hash->{$path})) {
- print color('bold yellow') . "WARNING: " . color('clear') .
- color('red') . "Multiple groups titled: $err_path!\n" .
- color('red') . "This is unsupported and may cause data loss!\n" .
- color('clear');
- }
- $hash->{$path}=$me->{id};
-
- if (defined($me->{groups})) {
- build_all_group_paths($hash,$me->{groups},\@path_to_me);
- }
- }
- return (scalar(keys(%{$hash})));
-}
-
-# Walks through a tree of groups building a flat hash of NULL-separated
-# paths to entry IDs. Called on the root to build a full path tree.
-sub build_all_entry_paths {
- my $hash = shift @_;
- my $g = shift @_;
- my $root_path = shift @_ || [];
-
- my $red=color('red');
-
- foreach my $me (@{$g}) {
- my @path_to_me = @{$root_path};
- push @path_to_me, $me->{title};
- if (defined($me->{entries})) {
- ENTRY: foreach my $ent (@{$me->{entries}}) {
- if ($ent->{'title'} eq 'Meta-Info' && $ent->{'username'} eq 'SYSTEM') {
- next ENTRY; # skip Meta-Info/SYSTEM entries
- }
- my $path=join( "\0", (@path_to_me, $ent->{title}) );
- my $err_path = '/' . humanize_path($path);
- if ($ent->{title} eq '') {
- print color('bold yellow') . "WARNING: " . color('clear') .
- $red . "There is an entry with a blank title in $err_path!\n" .
- color('clear');
- }
- if (defined($hash->{$path}) &&
- $err_path !~ m/\/Backup\/|\/Meta-Info$/) {
- print color('bold yellow') . "WARNING: " . color('clear') .
- $red . "Multiple entries titled: $err_path!\n" .
- $red . "This is unsupported and may cause data loss!\n" .
- color('clear');
- }
- $hash->{$path}=$ent->{id};
- }
- }
-
- if (defined($me->{groups})) {
- build_all_entry_paths($hash,$me->{groups},\@path_to_me);
- }
- }
- return (scalar(keys(%{$hash})));
-}
-
-# Returns the current path the user is sitting in.
-sub get_pwd {
- my $pwd='';
- if (defined($state->{all_grp_paths_rev}->{$state->{path}->{id}})) {
- $pwd=$state->{all_grp_paths_rev}->{$state->{path}->{id}};
- }
- $pwd =~ s%/%\\/%g;
- $pwd =~ s/\0/\//g;
- $pwd = '/' . $pwd;
-return $pwd;
-}
-
-# Destroys our /_found group (where we place search results)
-sub destroy_found {
- our $state;
- # Look for an exising /_found and kill it if it exists
- my $k=$state->{kdb};
- my $found_group=$k->find_group({level=>0,title=>$FOUND_DIR});
- if (defined($found_group)) {
- my @oldents = $k->find_entries({group=>$found_group->{id}});
- foreach my $ent (@oldents) {
- $k->delete_entry({id => $ent->{id}});
- }
- $k->delete_group({level=>0,title=>$FOUND_DIR});
-
- # Because we destroyed /_found we must refresh our $state paths
- refresh_state_all_paths();
- }
-}
-
-# Refreshes $state->{all_grp_paths_fwd} and $state->{all_grp_paths_rev}
-sub refresh_state_all_paths() {
- our $state;
-
- # Build all group paths
- my %all_grp_paths_fwd;
- build_all_group_paths(\%all_grp_paths_fwd,$state->{kdb}->groups);
- my %all_grp_paths_rev = reverse %all_grp_paths_fwd;
- $state->{all_grp_paths_fwd}=\%all_grp_paths_fwd;
- $state->{all_grp_paths_rev}=\%all_grp_paths_rev;
-
- # Build all entry paths
- my %all_ent_paths_fwd;
- build_all_entry_paths(\%all_ent_paths_fwd,$state->{kdb}->groups);
- my %all_ent_paths_rev = reverse %all_ent_paths_fwd;
- $state->{all_ent_paths_fwd}=\%all_ent_paths_fwd;
- $state->{all_ent_paths_rev}=\%all_ent_paths_rev;
-}
-
-# Gathers the list of groups and entries for the pwd we're sitting in
-sub get_current_groups_and_entries {
- return get_groups_and_entries(get_pwd());
-}
-sub get_groups_and_entries {
- my $path=shift @_;
- our $state;
-
- my $k=$state->{kdb};
-
- # Collect the @groups and entries
- my @groups=();
- my @entries=();
- my $norm_path = normalize_path_string($path);
- if (length($norm_path) < 1) {
- @groups = $k->find_groups({level=>0});
- @entries = $k->find_entries({level => 0});
- } else {
- my $id=$state->{all_grp_paths_fwd}->{$norm_path};
- my ($this_grp, at trash) = $k->find_groups({id=>$id});
- if (defined($this_grp->{groups})) { # subgroups
- @groups = @{$this_grp->{groups}};
- }
- @entries = $k->find_entries({group_id => $id});
- }
-
- # Remove Meta-Info/SYSTEM entries
- my @non_meta_info = ();
- foreach my $ent (@entries) {
- if (!($ent->{'title'} eq 'Meta-Info' && $ent->{'username'} eq 'SYSTEM')) {
- push @non_meta_info, $ent;
- }
- }
- @entries = @non_meta_info;
-
- # Sort the results
- @groups = sort group_sort @groups;
- @entries = sort { ncmp($a->{title},$b->{title}); } @entries;
-
- return (\@groups,\@entries);
-}
-
-# This function takes a group ID and returns all of the child
-# groups of that group, flattened.
-sub all_child_groups_flattened {
- my $group_id=shift @_;
- our $state;
-
- my $k=$state->{kdb};
- my @groups=();
- my ($this_grp, at trash) = $k->find_groups({id=>$group_id});
- if (defined($this_grp->{groups})) { # subgroups
- @groups = @{$this_grp->{groups}};
- foreach my $child_group (@groups) {
- push @groups, all_child_groups_flattened($child_group->{id});
- }
- }
-
- return @groups;
-}
-
-# A function to properly sort groups by title
-sub group_sort($$) {
- my $a=shift @_;
- my $b=shift @_;
-
- # _found at level 0 is a special case (from our find command).
- if ($a->{title} eq $FOUND_DIR && $a->{level} == 0) {
- return 1;
- } elsif ($b->{title} eq $FOUND_DIR && $b->{level} == 0) {
- return -1;
- # Backup at level=0 is a special case (KeePassX's Backup group).
- } elsif ($a->{title} eq 'Backup' && $a->{level} == 0) {
- return 1;
- } elsif ($b->{title} eq 'Backup' && $b->{level} == 0) {
- return -1;
- # "Recycle Bin" at level=0 is a special case (KeePass v2).
- } elsif ($a->{title} eq 'Recycle Bin' && $a->{level} == 0) {
- return 1;
- } elsif ($b->{title} eq 'Recycle Bin' && $b->{level} == 0) {
- return -1;
- # Sort everything else naturally (Sort::Naturally::ncmp).
- } else {
- return ncmp($a->{title},$b->{title}); # Natural sort
- }
-}
-
-# -------------------------------------------------------------------------
-# All of the cli_*() functions are below here
-# -------------------------------------------------------------------------
-
-# A simple wrapper function to block SIGTSTP (^Z) during certain commands
-sub run_no_TSTP {
- my $func = shift @_;
- $SIG{TSTP}='IGNORE';
- my @retval = &$func(@_);
- $SIG{TSTP}='DEFAULT';
- return @retval;
-}
-
-# Checks passwords for their quality
-sub cli_pwck {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- # If Data::Password is not avaiable we can't do this for the user
- if (! $state->{OPTIONAL_PM}->{'Data::Password'}->{loaded}) {
- print "Error: pwck requires the Data::Password module.\n";
- return;
- }
-
- my @targets = ();
- my $target = $params->{args}->[0];
- # Start by trying to find a single entity with the paramter given.
- # If no single entity is found then try to find entities based on
- # assuming that the path given is a group.
- my $ent=find_target_entity_by_number_or_path($target);
- if (defined($ent)) {
- push @targets, $ent;
- } else {
- my @groups = ();
- my $target = normalize_path_string($target);
- if ($target eq '' || $target eq '.' && get_pwd() eq '/') {
- @groups = $state->{kdb}->find_groups({}); # Every group in the file!
- } elsif (defined($state->{all_grp_paths_fwd}->{$target})) {
- my $group_id = $state->{all_grp_paths_fwd}->{$target};
- my $this_grp = $state->{kdb}->find_group( { id => $group_id } );
- @groups = all_child_groups_flattened($group_id);
- push @groups, $this_grp; # Push this group onto its children
- }
- # Loop over each target group adding each of its entries as targets
- foreach my $group (@groups) {
- if (defined($group->{entries})) {
- push @targets, @{$group->{entries}};
- }
- }
- }
-
- # Test each password, collect the results and record empty passwords
- my %results=();
- my @empties = ();
- print " working...\r";
- my @busy_chars = qw(\ | / -); my $i=0; my $in=10;
- foreach my $ent (@targets) {
- printf "%s\r", $busy_chars[int($i/$in)%($#busy_chars+1)] if (!($i++ % $in));
- my $pass = $state->{kdb}->locked_entry_password($ent);
- if (length($pass) == 0) {
- push @empties, $ent;
- $results{$ent->{id}} = '';
- } else {
- $results{$ent->{id}} = IsBadPassword($pass);
- if ($results{$ent->{id}} =~ m/dictionary word/i) {
- # IsBadPassword() reports dictionary words that it finds. I don't
- # like that from a security perspective so we change that here.
- $results{$ent->{id}} = "contains a dictionary word";
- }
- }
- # If the user hit ^C (SIGINT) then we need to stop
- if (recent_sigint()) {
- print "\r"; # Need to return to column 0 of the output line
- return 0;
- }
- }
-
- # If we only analyzed one password, return singular-style results
- if (scalar(@targets) == 1) {
- my $ent=$targets[0];
- if (length($results{$ent->{id}})) {
- print "Password concerns: " . $results{$ent->{id}} . "\n";
- } elsif (scalar(@empties) > 0) {
- print "Password field is empty.\n";
- } else {
- print "Password strength is good.\n";
- }
- } else {
- # If we analyzed more than one password, return multiple-style results
- my %problems=();
- foreach my $ent_id (keys %results) {
- if (length($results{$ent_id})) {
- $problems{$state->{all_ent_paths_rev}->{$ent_id}} = $ent_id;
- }
- }
- my $analyzed = scalar(@targets);
- my $problem_count = scalar(keys %problems);
- my $empty_count = scalar(@empties);
- print "$analyzed passwords analyzed, $empty_count blank, " .
- "$problem_count concerns found";
- if ($problem_count > 0) { print ":"; } else { print "."; }
- print "\n";
- foreach my $path (sort keys %problems) {
- print humanize_path($path) . ": $results{$problems{$path}}\n";
- }
- }
-
- return 0;
-}
-
-# Prints some statistics about the open KeePass file
-sub cli_stats {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- # Group and entry counts
- my %stats;
- $stats{group_count} = scalar(keys(%{$state->{all_grp_paths_fwd}}));
- $stats{entry_count} = scalar(keys(%{$state->{all_ent_paths_fwd}}));
-
- # Password lengths
- my $k=$state->{kdb};
- my %password_lengths;
- print " working...\r";
- my @busy_chars = qw(\ | / -); my $i=0; my $in=100;
- foreach my $ent_id (values(%{$state->{all_ent_paths_fwd}})) {
- printf "%s\r", $busy_chars[int($i/$in)%($#busy_chars+1)] if (!($i++ % $in));
- my $ent = $k->find_entry({id => $ent_id});
- my $pass_len = length($k->locked_entry_password($ent));
- if ($pass_len < 1) {
- $password_lengths{"0"}++;
- } elsif ($pass_len > 0 && $pass_len < 8) {
- $password_lengths{"1-7"}++;
- } elsif ($pass_len > 7 && $pass_len < 12) {
- $password_lengths{"8-11"}++;
- } elsif ($pass_len > 11 && $pass_len < 17) {
- $password_lengths{"12-16"}++;
- } elsif ($pass_len > 16 && $pass_len < 20) {
- $password_lengths{"17-19"}++;
- } elsif ($pass_len > 19) {
- $password_lengths{"20+"}++;
- }
- # If the user hit ^C (SIGINT) then we need to stop
- if (recent_sigint()) {
- print " "x20 . "\r"; # Need to return to column 0 of the output line
- return 0;
- }
- }
-
- my $t= " "x20 . "\r" .
- "File: " . $state->{kdb_file} . "\n" .
- "Key file: " .
- (defined($state->{key_file}) ? $state->{key_file} : 'N/A') . "\n";
- if (defined($k->{header}->{database_name})) {
- $t.="Name: " . $k->{header}->{database_name} . "\n";
- }
- if (defined($k->{header}->{database_description})) {
- my $desc = $k->{header}->{database_description};
- $desc =~ s/[\r\n]/\n/g;
- my @l = split(/\n/, $desc);
- $t .= "Description:\n" . "| " . join("\n| ", @l) . "\n";
- }
- $t .= "KeePass file version: " . $k->{header}->{version} . "\n" .
- "Encryption type: " . $k->{header}->{enc_type} . "\n" .
- "Encryption rounds: " . $k->{header}->{rounds} . "\n";
- if (defined($k->{header}->{cipher})) {
- $t.="Cipher: $stats{cipher}\n";
- }
- if (defined($k->{header}->{compression})) {
- $t.="Compression: $stats{compression}\n";
- }
- $t .= "Number of groups: $stats{group_count}\n" .
- "Number of entries: $stats{entry_count}\n" .
- "Entries with passwords of length:\n".stats_print(\%password_lengths) .
- "\n" .
- "";
- print $t;
-}
-
-sub cli_cls {
- if (lc($OSNAME) =~ m/^mswin/ &&
- (! $OPTIONAL_PM{'Win32::Console::ANSI'}->{loaded})) {
- system("cls");
- } else {
- print "\033[2J\033[0;0H";
- $|=1; # Needed for MS Windows (Win32::Console::ANSI works w/this flush)
- }
-}
-
-sub cli_pwd {
- print get_pwd() . "\n";
-}
-
-sub cli_cd {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- my $raw_pathstr = $params->{args}->[0];
- # "cd ."
- if ($raw_pathstr =~ m/^[.]$/) {
- return; # nothing to do
- }
- # "cd -"
- if ($raw_pathstr =~ m/^[-]$/) {
- return cli_cd($self, {'args' => [$state->{oldpwd}]});
- }
- # Everything else is handled by helpers
- return cli_cd_helper($state,normalize_path_string($raw_pathstr));
-}
-
-# Takes a possibly wacky path with ".."s and such and normalizes it into a
-# NULL-separated path that can be used as a key into $state->{all_grp_paths_fwd}
-sub normalize_path_string($) {
- my $path_string = shift @_;
- our $state;
-
- # Split the path into @path
- # http://efreedom.com/Question/1-3588341/Implement-Escape-Sequence-Using-Split-Perl
- my $delim="/";
- my $escape="\\";
- my @path = $path_string =~
- /(?:\Q$delim\E|^)((?>(?:\Q$escape\E.|(?!\Q$delim\E).)*))/gs;
- #/(?:\Q$delim\E|^)((?:\Q$escape\E.|(?!\Q$delim\E).)*+)/gs; # perl 5.10+
- s/\Q$escape$delim\E/$delim/g for @path;
- @path=grep(!/^$/, @path); # Drop meaningless (excess) deimiters (/foo//bar)
-
- # This block handles absolute and relative paths
- my $path_str='';
- if ($path_string =~ m%^/%) { # Absolute path
- $path_str=join("\0", at path);
- } else { # Relative path
- my $pwd=$state->{all_grp_paths_rev}->{$state->{path}->{id}};
- my @nwd=split("\0", $pwd);
- push @nwd, @path;
- $path_str=join("\0", @nwd);
- }
-
- # We should now have a NULL-separated, fully qualified path and
- # we just need to work on cleaning up things like "." and ".."
- # Squash single dots
- while ($path_str=~s/\0[.]\0|^[.]\0|\0[.]$/\0/) {}
- # Kill ".." and their parents
- while ($path_str=~s/[^\0]+\0+[.][.]//) {}
- $path_str=~s/\0\0+/\0/g; # squash any adjacent delimeters
- $path_str=~s/^\0+//; # squash any leading delimeters
- $path_str=~s/\0+$//; # squash any trailing delimeters
-
- return $path_str;
-}
-
-sub cli_cd_helper($$) {
- my $state=shift @_;
- my $path_str=shift @_;
- if (defined($state->{all_grp_paths_fwd}->{$path_str})) {
- $state->{oldpwd}=get_pwd();
- my $id=$state->{all_grp_paths_fwd}->{$path_str};
- $state->{path}->{id}=$id;
- return 0;
- } elsif ($path_str eq '') { # cd /
- $state->{oldpwd}=get_pwd();
- delete $state->{path};
- return 0;
- } else {
- print "Invalid path\n";
- return -1;
- }
-}
-
-# Return 1 if entry's password never expires, else 0.
-# Helps support both v1 (*.kdb) and v2 (*.kdbx) files.
-sub does_expire_never {
- my $kdb_ver = shift @_;
- my $ent = shift @_;
- my $expires = $ent->{expires};
- if ($kdb_ver == 1 && $expires =~ m/^(\d{4})-/ && ($1 == 2999)) {
- return 1;
- } elsif ($kdb_ver == 2 && (! $ent->{expires_enabled})) {
- return 1;
- }
- return 0;
-}
-
-# Helper function of cli_find() to find expired entries.
-sub find_expired_entries {
- my $kdb_ver = shift @_;
- my $k = shift @_;
- our $state;
-
- my @expired = ();
- my @all_entries_flattened = $k->find_entries({});
- ENT: foreach my $ent (@all_entries_flattened) {
- if (recent_sigint()) { return (); } # Bail on SIGINT
- # If we don't have this entry recorded in all_ent_paths_rev then
- # we can't report on it. The only case known of is when we have
- # multiple entries at the same level (unsupported) and typically
- # that's only seen in /Backup in v1 databases.
- if (! defined($state->{all_ent_paths_rev}->{$ent->{id}})) {
- next ENT;
- }
- # Now test the expired time...
- if (does_expire_never($kdb_ver, $ent)) { next ENT; }
- my $exp = $ent->{expires};
- my $title = $ent->{title};
- if ($exp !~ m/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/) {
- print "Warning: Invalid expiration date found in \"$title\"\n";
- next ENT;
- }
- my ($year,$mon,$mday,$hour,$min,$sec) = ($1, $2, $3, $4, $5, $6);
- my $timegm = timegm($sec,$min,$hour,$mday,$mon-1,$year);
- if ($timegm < time) {
- push @expired, $ent;
- }
- }
- return @expired;
-}
-
-sub cli_find($) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- destroy_found();
-
- # Users can provide a -a option to search more than just the title. We
- # use GetOpts to parse this command line.
- my $search_str='';
- my %opts=();
- {
- local @ARGV = @{$params->{args}};
- my $result = &GetOptions(\%opts, 'a', 'expired');
- if (defined($opts{'expired'})) {
- if (scalar(@ARGV) || scalar(keys(%opts)) > 1) {
- print "-expired cannot have search criteria other qualifiers.\n";
- return;
- }
- } else {
- if (scalar(@ARGV) != 1) {
- print "Find only supports searching for one word.\n";
- return;
- }
- $search_str = $ARGV[0];
- }
- }
-
- my @e = ();
- my $k=$state->{kdb};
-
- if (length($search_str)) {
- print "Searching for \"$search_str\" ...\n";
-
- # Make $search_str a case-insensitive regex
- my @letters=split(//, $search_str);
- foreach my $l (@letters) {
- if (uc($l) ne lc($l)) {
- $l='[' . uc($l) . lc($l) . ']';
- }
- }
- $search_str=join('', @letters);
-
- my @srch_flds = qw(title);
- if ($opts{'a'}) {
- @srch_flds = qw(title username comment url tags);
- }
- foreach my $fld (@srch_flds) {
- # Search entries by title, skipping the /Backup (*.kdb) and/or
- # "/Recycle Bin" groups if they exists
- my $search_params = { "$fld =~" => $search_str };
- foreach my $bu_dir ('/Backup','/Recycle Bin') {
- my $backup_dir_normalized=normalize_path_string($bu_dir);
- if (defined($state->{all_grp_paths_fwd}->{$backup_dir_normalized})) {
- $search_params->{'group_id !'} =
- $state->{all_grp_paths_fwd}->{$backup_dir_normalized};
- }
- }
- push @e, $k->find_entries($search_params);
- }
- } elsif (defined($opts{'expired'})) {
- @e = find_expired_entries($state->{kdb_ver}, $k);
- }
-
- # Remove Meta-Info/SYSTEM entries
- my @non_meta_info = ();
- foreach my $ent (@e) {
- if (!($ent->{'title'} eq 'Meta-Info' && $ent->{'username'} eq 'SYSTEM')) {
- push @non_meta_info, $ent;
- }
- }
- @e = @non_meta_info;
-
- if ( scalar(@e) < 1) {
- print "No matches.\n";
- return;
- }
-
- # If we get this far we have results to add to a new /_found
- my $found_group = $k->add_group({title => $FOUND_DIR}); # root level group
- my $found_gid = $found_group->{'id'};
- $k->unlock;
- my @matches=();
- my %duplicates=();
- FINDS: foreach my $ent (@e) {
- my %new_ent = %{clone($ent)}; # Clone the entity
- $new_ent{id} = int(rand(1000000000000000)); # A random new id
- $new_ent{group} = $found_gid; # Place this entry clone into /_found
- # $new_ent{path} is _NOT_ a normal key for File::KeePass but this is
- # safe because we are adding it to entries in the /_found group which
- # will not be saved to a file.
- my $nulled_path=$state->{all_ent_paths_rev}->{$ent->{id}};
- $new_ent{path} = '/' . dirname(humanize_path($nulled_path)) . '/';
- $new_ent{full_path} = '/' . humanize_path($nulled_path);
- if (defined($duplicates{$new_ent{full_path}})) { next FINDS; }
- $duplicates{$new_ent{full_path}} = 1;
- $k->add_entry(\%new_ent);
- push(@matches, \%new_ent);
- # If the user hit ^C (SIGINT) then we need to stop
- if (recent_sigint()) {
- # If we delete the $FOUND_DIR group it should be safe to leave
- $k->delete_group({title => $FOUND_DIR});
- return undef;
- }
- }
- $k->lock;
-
- # Because we added a new /_found we must refresh our $state paths
- refresh_state_all_paths();
-
- # Tell the user what we found
- print " - ".scalar(@matches)." matches found and placed into /$FOUND_DIR/\n";
-
- # If we only found one, ask the user if they want to see it
- if (scalar(@matches) == 1) {
- print "Would you like to show this entry? [y/N] ";
- my $key=get_single_key();
- print "\n";
- if (lc($key) eq 'y') {
- my $search_params = { 'group_id =' => $found_gid };
- my ($e, at empty) = $k->find_entries($search_params);
- my $full_path="/$FOUND_DIR/" . $e->{title};
- my $show_args = [ $full_path ];
- # If we are doing an expired password search, find only one entry,
- # and give the user the option to show it, we want to show the
- # expired time, and so we add -a here.
- if (defined($opts{'expired'})) { push @{$show_args}, '-a'; }
- cli_show($self, { args => $show_args });
- }
- }
-}
-
-# Something is going wrong between KeePassX and File::KeePass related to
-# the unknown values read/written by File::KeePass from/to files written
-# by KeePassX. Commenting out line 378 of File/KeePass.pm is one fix,
-# this prevents me from needing to do that by just removing the unknown
-# values before saving. If there is a downside to this on the KeePassX
-# side I've not found it yet. I do have an email out to Paul, the author
-# of File::KeePass, requesting some assistance in grokking the problem.
-#
-# NOTE: I thought that this should not be needed for File::Keepass >= 0.3,
-# but on 2011-02-02 I discovered that creating new groups with
-# File::Keepass and not scrubbing on save created corrupt files:
-# "Group header offset is out of range" errors.
-# Sourceforge bug# 3187054 demonstrated the problem as well.
-sub scrub_unknown_values_from_all_groups {
- our $state;
- # No need to do this with newer versions of File::KeePass (fixed in 2.01)
- if (version->parse($File::KeePass::VERSION) >= version->parse('2.03')) {
- return;
- }
- my $k=$state->{kdb};
- my @all_groups_flattened = $k->find_groups({});
- my @unkown_field_groups=();
- foreach my $g (@all_groups_flattened) {
- if (defined($g->{unknown})) {
- #warn "LHHD: " . &Dumper($g->{unknown}) . "\n";
- delete $g->{unknown};
- push @unkown_field_groups, $g->{title};
- }
- }
- my $count = scalar(@unkown_field_groups);
- if ($count > 0) {
- warn "Deleted unknown fields from these $count groups: " .
- join(", ", @unkown_field_groups) . "\n";
- }
-}
-
-sub deny_if_readonly {
- our %opts;
- if (defined($opts->{readonly}) && int($opts->{readonly})) {
- print "Function not available with --readonly set.\n";
- return 1;
- }
- return 0;
-}
-
-sub cli_save($) {
- my $self = shift @_;
- my $params = shift @_;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
- if (deny_if_readonly()) { return; }
-
- our $state;
- if (! length($state->{kdb_file})) {
- print "Please use the saveas command for new files.\n";
- return;
- }
-
- # If the user has asked for a *.kdbx file, check the File::KeePass version
- if (version->parse($File::KeePass::VERSION) < version->parse('2.03')) {
- if ($state->{kdb_file} =~ m/\.kdbx$/i) {
- print "KeePass v2 (*.kdbx files) require File::KeePass >= v2.03\n";
- return;
- }
- }
-
- if (warn_if_file_changed()) {
- return;
- }
-
- # Check for a lock file that we did not place there
- my $lock_file = $state->{kdb_file} . '.lock'; # KeePassX style
- if (-f $lock_file && $state->{placed_lock_file} ne $lock_file) {
- print color('bold yellow') . "WARNING:" . color('clear') .
- color('red') .
- " A KeePassX-style lock file is in place for this file.\n" .
- " It may be opened elsewhere. Save anyway? [y/N] " .
- color('clear');
- my $key=get_single_key();
- print "\n";
- if (lc($key) ne 'y') {
- return;
- }
- }
-
- # If we got this far the user is OK with us locking the file even
- # if we had a contention.
- touch_file($lock_file);
- $state->{placed_lock_file} = $lock_file;
-
- # Scrub the data and write the file
- destroy_found();
- scrub_unknown_values_from_all_groups(); # TODO - remove later
- my $k=$state->{kdb};
- $k->unlock;
- my $master_pass=
- composite_master_pass($state->{get_master_passwd}(),$state->{key_file});
- $k->save_db($state->{kdb_file},$master_pass);
- $state->{kdb_has_changed}=0; # set our state to no change since last save
- $master_pass="\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
- print "Saved to $state->{kdb_file}\n";
- $k->lock;
-
- # Update the md5sum of the file after we just saved it
- my $file = $state->{kdb_file};
- $state->{kdb_file_md5} = Digest::file::digest_file_hex($file, "MD5");
-}
-
-# This subroutine handles the clipboard commands (xw, xu, xp, and xx)
-sub cli_xN($$) {
- my $xNcmd = shift @_;
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- # If Clipboard is not avaiable we can't do this for the user
- if (! $state->{OPTIONAL_PM}->{'Clipboard'}->{loaded}) {
- print "Error: $xNcmd requires the Clipboard and Capture::Tiny modules:\n" .
- " - http://search.cpan.org/~king/Clipboard/\n" .
- " - http://search.cpan.org/~dagolden/Capture-Tiny/\n" .
- "";
- if (defined($state->{OPTIONAL_PM}->{'Clipboard'}->{error})) {
- print "\nThere was an error loading the Clipboard module, as follows:\n" .
- $state->{OPTIONAL_PM}->{'Clipboard'}->{error} . "\n";
- }
- return;
- }
-
- # If we're clearing the clipboard, just do that and return immediately.
- if ($xNcmd eq 'xx') {
- Clipboard->copy('');
- print "Clipboard cleared.\n";
- return;
- }
-
- # Find the entry that the user wants to copy to the clipboard from.
- my $target = $params->{args}->[0];
- my $ent=find_target_entity_by_number_or_path($target);
- if (! defined($ent)) {
- print "Don't see an entry at path: $target\n";
- return -1;
- }
-
- # Switch over the xN commands and place the data into $to_copy
- my $to_copy = '';
- SWITCH: {
- $xNcmd eq 'xu' && do { $to_copy = $ent->{username}; last SWITCH; };
- $xNcmd eq 'xw' && do { $to_copy = $ent->{url}; last SWITCH; };
- $xNcmd eq 'xp' && do {
- $to_copy = $state->{kdb}->locked_entry_password($ent);
- last SWITCH; };
- warn "Error: cli_xN() does not know how to handle the $xNcmd command.";
- $to_copy = undef;
- }
-
- # Copy to the clipboard and tell the user what we did.
- my $cp_map = {
- 'xu' => 'username',
- 'xw' => 'url',
- 'xp' => 'password',
- };
- if (defined($to_copy)) {
- Clipboard->copy($to_copy);
- print "Copied $cp_map->{$xNcmd} for \"$ent->{title}\" to the clipboard.\n";
- }
-
- return;
-}
-
-sub cli_rm($) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
- return;
- }
-
- my $target = $params->{args}->[0];
- my $ent=find_target_entity_by_number_or_path($target);
- if (! defined($ent)) {
- print "Don't see an entry at path: $target\n";
- return -1;
- }
-
- # Recycle the entry unless --no-recycle
- if (! (defined($opts->{'no-recycle'}) && int($opts->{'no-recycle'}))) {
- my $errmsg = recycle_entry($state, $ent);
- if (defined($errmsg)) { print "WARNING: $errmsg\n"; }
- }
-
- $state->{kdb}->delete_entry({ id => $ent->{id} });
- $state->{kdb_has_changed}=1;
- refresh_state_all_paths();
- RequestSaveOnDBChange();
-}
-
-sub validate_entry_number($$) {
- my $item_number=shift @_;
- my $path=shift @_;
- our $state;
-
- if (normalize_path_string($path) eq '') {
- print "Entries cannot exist in the root path (/).\n";
- return -1;
- }
-
- if ($item_number !~ m/^[0-9]+$/) {
- print "Invalid item number (must be an integer).\n";
- return -1;
- }
-
- my ($rGrps,$rEnts) = get_groups_and_entries($path);
- my $entry_max=scalar(@{$rEnts}) - 1;
- if ($entry_max < 0) {
- print "Invalid item number. No valid entries in $path.\n";
- return -1;
- } elsif ($item_number > $entry_max) {
- print "Invalid item number. Valid entries in \"$path\" are 0-$entry_max.\n";
- return -1;
- }
-return 0;
-}
-
-# This routine takes one parameter that will be either a path
-# to an entity or an entity number as shown my the ls command
-# and will use $state information such as last_ls_path to
-# return a reference to that entity in the $state-{kdb} database,
-# if possible (valid input).
-sub find_target_entity_by_number_or_path($) {
- my $target=shift @_;
- our $state;
-
- my $ent=undef; # hope to populate this in a second...
-
-
- # This section looks for an entity by an "ls" number
- if ($target =~ m/^[0-9]+$/) {
- my $path=$state->{last_ls_path};
- if (!length($path)) { $path=get_pwd(); }
- if (! validate_entry_number($target,$path)) {
- my ($rGrps,$rEnts) = get_groups_and_entries($path);
- $ent=$rEnts->[$target];
- }
- }
-
- # This section looks by a path name
- if (defined $state->{all_ent_paths_fwd}->{normalize_path_string($target)}) {
- my $entry_id=$state->{all_ent_paths_fwd}->{normalize_path_string($target)};
- $ent = $state->{kdb}->find_entry( {id=>$entry_id} );
- }
-
- # If we found the entry, place the path to this entry in the entry record,
- # if it's not already there _and_ if the path we have != $FOUND_DIR.
- if (defined($ent) &&
- (!defined($ent->{path})) && $ent->{path} !~ m/^\Q$FOUND_DIR\E$/) {
- $ent->{full_path} = '/' .
- humanize_path($state->{all_ent_paths_rev}->{$ent->{id}});
- $ent->{path} = dirname($ent->{full_path});
- }
-
- return $ent;
-}
-
-sub cli_rename($$) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
- return;
- }
-
- my $target_dir = $params->{args}->[0];
- my $dir_normalized=normalize_path_string($target_dir);
- my $grp=undef;
- if (defined($state->{all_grp_paths_fwd}->{$dir_normalized})) {
- my $grp_id = $state->{all_grp_paths_fwd}->{$dir_normalized};
- $grp=$state->{kdb}->find_group( { id => $grp_id } );
- }
- if (! defined($grp)) {
- print "Unknown group: $target_dir\n";
- return -1;
- }
-
- my $prompt = color('clear')."Enter the groups new Title: ";
- my $term = get_prepped_readline_term();
- my $new_title = $term->readline($prompt);
- # If the user hit ^C (SIGINT) then we need to stop
- if (recent_sigint()) { return undef; }
- chomp($new_title);
- if (length($new_title)) {
- if ($new_title =~ m/\//) {
- print "kpcli cannot support titles with slashes (/) in them.\n";
- return undef;
- }
- # If the titles are the same then there is nothing to do
- if ($new_title eq $grp->{title}) { return 0; }
- # Check the new title for same-name conflicts in its group
- my $path = dirname('/'.humanize_path($dir_normalized));
- my $testdir=normalize_path_string("$path/$new_title");
- if (defined($state->{all_grp_paths_fwd}->{$testdir})) {
- print "An entry titled \"$new_title\" is already in $path.\n";
- return undef;
- }
- # If we passed all of our sanity checks then set the new title
- $grp->{title} = $new_title;
- } else {
- return 0;
- }
-
- # Because we renamed a group we must refresh our $state paths
- refresh_state_all_paths();
- $state->{kdb_has_changed}=1;
- RequestSaveOnDBChange();
-}
-
-sub cli_mv($$) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
- return;
- }
-
- # The target has to be a group. We start validation there (the target).
- my $target_dir = $params->{args}->[1];
- my $dir_normalized=normalize_path_string($target_dir);
- my $grp=undef;
- if (defined($state->{all_grp_paths_fwd}->{$dir_normalized})) {
- my $grp_id = $state->{all_grp_paths_fwd}->{$dir_normalized};
- $grp=$state->{kdb}->find_group( { id => $grp_id } );
- }
- if (! defined($grp)) {
- print "Unknown group: $target_dir\n";
- return -1;
- }
-
- # The source (thing we are moving) can be an entity or group, and
- # here we figure out which one and prepare to exectute the move below.
- my $src_path = normalize_path_string($params->{args}->[0]);
- my $ent=undef;
- my $mv_type = undef;
- if ($ent=find_target_entity_by_number_or_path($src_path)) {
- $mv_type = 'entry';
- } elsif (defined($state->{all_grp_paths_fwd}->{$src_path})) {
- $mv_type = 'group';
- } else {
- print "Unknown entity: $src_path\n";
- return -1;
- }
-
- # Execute the move of the entry or group.
- if ($mv_type eq 'entry') {
- # Verify no entry title conflict at the new location
- my $new_entry_path=normalize_path_string($target_dir . "/" . $ent->{title});
- if (defined($state->{all_ent_paths_fwd}->{$new_entry_path})) {
- my $path = dirname(humanize_path($new_entry_path));
- print "There is already and entry named \"$ent->{title}\" at $path/.\n";
- return undef;
- }
-
- # Unlock the kdb, clone the entry, remove its ID and set its new group,
- # add it to the kdb, delete the old entry, then lock the kdb...
- $state->{kdb}->unlock;
- my %ent_copy = %{clone($ent)}; # Clone the entity
- delete $ent_copy{id};
- $ent_copy{group} = $grp;
- if ($state->{kdb}->add_entry(\%ent_copy)) {
- $state->{kdb}->delete_entry({ id=>$ent->{id} });
- }
- $state->{kdb}->lock;
- } elsif ($mv_type eq 'group') {
- # Find the group that the user is asking us to move
- my $src_grp=$state->{kdb}->find_group(
- {id => $state->{all_grp_paths_fwd}->{$src_path}});
- # Clone the group that is to be moved
- my %new_group = %{$src_grp};
- # Delete the id and level from the cloned group
- delete $new_group{id};
- delete $new_group{level};
- # Add the group ID of the parent we want to put our clone under
- $new_group{group} = $grp->{id}; # Set the new parent group
- # Add the clone as a new group
- $state->{kdb}->add_group(\%new_group);
- # Delete the original group that we just cloned into a new spot
- $state->{kdb}->delete_group({ id => $src_grp->{id} });
- } else {
- print "Unknown error with move command.\n";
- return -1;
- }
-
- # Because we moved an entry we must refresh our $state paths
- refresh_state_all_paths();
- $state->{kdb_has_changed}=1;
- RequestSaveOnDBChange();
-}
-
-sub cli_copy {
- my $self = shift @_;
- my $params = shift @_;
- my $skip_save = shift @_ || 0;
- our $state;
-
- if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
- return;
- }
-
- my $source_ent = $params->{args}->[0];
- my $src_ent=find_target_entity_by_number_or_path($source_ent);
- if (! defined($src_ent)) {
- print "Unknown entry: $source_ent\n";
- return -1;
- }
-
- my $target_ent = $params->{args}->[1];
- my $trg_ent=find_target_entity_by_number_or_path($target_ent);
- if (defined($trg_ent)) {
- print "Copy cannot overwrite an existing entry.\n";
- return -1;
- }
-
- # Unlock the kdb, clone the entry, remove its ID and set its new group,
- # add it to the kdb, delete the old entry, then lock the kdb...
- $state->{kdb}->unlock;
- my %ent_copy = %{clone($src_ent)}; # Clone the entity
- $state->{kdb}->lock;
- delete $ent_copy{id}; # Remove the entry ID for the new copy
- my ($grp_path,$name)=normalize_and_split_raw_path($target_ent);
- $ent_copy{title} = humanize_path($name);
- # group needs to be set to the ID of the group we may be moving the copy to
- my $new_grp_id = $state->{all_grp_paths_fwd}->{$grp_path};
- if (! defined($new_grp_id)) {
- print "Copy failed due to missing target group ID.\n";
- return -1;
- } else {
- $ent_copy{group} = $new_grp_id;
- }
- $state->{kdb}->unlock;
- if (! $state->{kdb}->add_entry(\%ent_copy)) {
- print "Copy failed on add_entry().\n";
- $state->{kdb}->lock;
- return -1;
- }
- $state->{kdb}->lock;
-
- # Because we added an entry we must refresh our $state paths
- if (! $skip_save) {
- refresh_state_all_paths();
- $state->{kdb_has_changed}=1;
- RequestSaveOnDBChange();
- }
- return 0;
-}
-
-sub cli_clone($$) {
- my $self = shift @_;
- my $params = shift @_;
- my $skip_save = shift @_ || 0;
- our $state;
-
- if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
- return;
- }
-
- my $skip_save = 1;
- my $retval_copy = cli_copy($self, $params, $skip_save);
- if ($retval_copy) {
- return -1;
- }
- refresh_state_all_paths();
-
- my $target_ent = $params->{args}->[1];
- my $ent=find_target_entity_by_number_or_path($target_ent);
- if (! (defined($ent) && exists($ent->{id}))) {
- print "Copy failed.\n";
- return -1;
- }
-
- $state->{kdb_has_changed}=1;
- my %changes = ();
- my $retval_edit = _entry_edit_gui($ent, \%changes);
-
- # Apply the user changes, update modify time and prompt to save
- if ($retval_edit == 0) {
- $state->{kdb}->unlock; # Required for the password field
- foreach my $key (keys %changes) { $ent->{$key} = $changes{$key}; }
- $state->{kdb}->lock;
- $ent->{modified} = $state->{kdb}->now;
- refresh_state_all_paths();
- $state->{kdb_has_changed}=1;
- RequestSaveOnDBChange();
- return 0;
- } else {
- # If the edit failed (or if the user hit ^C while in edit_gui), then
- # remove the target entity that cli_copy() made.
- my $ent_id=$ent->{id};
- $state->{kdb}->delete_entry({ id => $ent_id });
- refresh_state_all_paths();
- $state->{kdb_has_changed}=0; # Should be back to how we started
- return $retval_edit;
- }
-}
-
-sub cli_show($$) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- # Users can provide a -f option to show the password. We use GetOptions
- # to parse this command line, and $target holds that target.
- my $target='';
- my %opts=();
- {
- local @ARGV = @{$params->{args}};
- my $result = &GetOptions(\%opts, 'f', 'a');
- if (scalar(@ARGV) != 1) {
- return -1;
- }
- $target = $ARGV[0];
- }
-
- my $ent=find_target_entity_by_number_or_path($target);
- if (! defined($ent)) {
- return -1;
- }
-
- print "\n";
- if (defined($ent->{path})) {
- print show_format("Path",$ent->{path}) . "\n";
- }
- # Unless -f is specified, we "hide" the password as red-on-red.
- my $password = $state->{kdb}->locked_entry_password($ent);
- if (! defined($opts{f})) {
- $password = colored(['red on_red'], $password);
- }
- # Print the entry for the user
- print
- show_format("Title",$ent->{title}) . "\n" .
- show_format("Uname",$ent->{username}) . "\n" .
- show_format("Pass",$password) . "\n" .
- show_format("URL",$ent->{url}) . "\n" .
- show_format("Notes",$ent->{comment}) . "\n" .
- ($DEBUG ? show_format("ID",$ent->{id}) . "\n" : '');
- # Tags were added to KeePass in v2.11 (*.kdbx)
- if (defined($ent->{tags}) && length($ent->{tags})) {
- print show_format("Tags", humanize_entry_tags($ent->{tags})) . "\n";
- }
- # Check for strings and file attachments in this entry
- foreach my $key (qw(strings binary)) {
- my $attachment = show_helper_files_strings($ent,{f=>1},$key);
- #my $attachment = show_helper_files_strings($ent, \%opts, $key);
- if (length($attachment)) { print $attachment; }
- }
- # If -a was given, tack on those details
- if (defined($opts{a})) {
- print
- show_format("Icon#",$ent->{icon}) . "\n" .
- show_format("Creat",$ent->{created}) . "\n" .
- show_format("Modif",$ent->{modified}) . "\n";
- if (defined($ent->{expires})) {
- my $expires = $ent->{expires};
- if (does_expire_never($state->{kdb_ver}, $ent)) {
- $expires = 'Never';
- }
- print show_format("Xpire",$expires) . "\n";
- }
- }
- print "\n";
- print &Dumper($ent) . "\n" if ($DEBUG > 2);
-}
-
-# A helper function to display key/val pairs and file attachments.
-# Note that strings (key/val pairs) and multiple attachments per
-# entry only exist in KeePass 2.x (kdbx) files, while verions 1.x
-# files (kdb) support only a single file attachment per entry.
-sub show_helper_files_strings {
- my $ent = shift @_;
- my $opts = shift @_;
- my $key = shift @_;
- my $labels = {
- 'binary' => ['Atchm','File Attachments'],
- 'strings' => ['Strgs','String Values'],
- };
- my @atts=();
- if (defined($ent->{$key})) {
- foreach my $name (sort keys %{$ent->{$key}}) {
- if ($key eq 'binary') {
- push @atts, "$name (". int(length($ent->{$key}->{$name})) ." bytes)";
- } elsif ($key eq 'strings') {
- if (defined($opts->{f})) {
- push @atts, "$name = " . $ent->{$key}->{$name};
- } else {
- push @atts, "$name = ".colored(['red on_red'],$ent->{$key}->{$name});
- }
- }
- }
- }
- my $attachment = '';
- my $label = $labels->{$key}[0];
- if (scalar(@atts) == 0 && defined($opts->{v})) {
- $attachment = "None";
- } elsif (scalar(@atts) == 1) {
- $attachment = $atts[0];
- } elsif (scalar(@atts) > 1) {
- $label = $labels->{$key}[1];
- my @t=();
- foreach my $num (1..scalar(@atts)) {
- push(@t, sprintf("%2d) %s", $num, $atts[$num-1]));
- }
- $attachment = "\n " . join("\n ", @t) . "\n";
- }
- return ($attachment ne '' ? show_format($label,$attachment) . "\n" : '');
-}
-
-sub cli_edit {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
- return;
- }
-
- my $target = $params->{args}->[0];
- my $ent=find_target_entity_by_number_or_path($target);
- if (! defined($ent)) {
- print "Don't see an entry at path: $target\n";
- return -1;
- }
-
- my %changes = ();
- my $retval = _entry_edit_gui($ent, \%changes, $state->{kdb_ver});
-
- # If the user made changes, apply them, update modify time and prompt to save
- if ($retval == 0 && scalar(keys(%changes)) > 0) {
- # Recycle the entry unless --no-recycle
- if (! (defined($opts->{'no-recycle'}) && int($opts->{'no-recycle'}))) {
- my $errmsg = recycle_entry($state, $ent);
- if (defined($errmsg)) { print "WARNING: $errmsg\n"; }
- }
- # Update the entry's changed fields
- $state->{kdb}->unlock; # Required for the password field
- foreach my $key (keys %changes) { $ent->{$key} = $changes{$key}; }
- $state->{kdb}->lock;
- $ent->{modified} = $state->{kdb}->now;
- refresh_state_all_paths();
- $state->{kdb_has_changed}=1;
- RequestSaveOnDBChange();
- }
-
-return 0;
-}
-
-# A routine to copy entries to the /Backup or "/Recycle Bin" folders.
-# Used by cli_edit, cli_rm, etc.
-sub recycle_entry {
- my $state = shift @_;
- my $ent = shift @_;
- my $bugrp_id = get_recycle_group_path($state, 1);
- if (defined($bugrp_id)) {
- $state->{kdb}->unlock; # Required for the password field
- my $tmp_ent = clone($ent); # Clone the entity and back it up
- delete $tmp_ent->{id};
- # Append the modified time to the title for the backup dir
- $tmp_ent->{title} = $tmp_ent->{title} . " (". $tmp_ent->{modified} .")";
- $tmp_ent->{group} = $bugrp_id;
- $tmp_ent->{modified} = $state->{kdb}->now;
- my $add_result = $state->{kdb}->add_entry($tmp_ent);
- $state->{kdb}->lock;
- if (! $add_result) {
- return "Failed to copy the entry to the backup folder.";
- } else {
- $state->{kdb_has_changed}=1;
- }
- refresh_state_all_paths();
- }
- return undef;
-}
-
-# Returns /Backup or "/Recycle Bin" based on KeePass v1 or v2 file.
-# Creates the group if it is missing and $create_if_missing in true.
-sub get_recycle_group_path {
- my $state = shift @_;
- my $create_if_missing = shift @_ || 0;
- my $bugrp = "/Backup"; # Default to v1 (is this correct?)
- if ($state->{kdb_ver} == 2) {
- $bugrp = "/Recycle Bin";
- }
- my $grp_path = normalize_path_string($bugrp);
- if ($create_if_missing) {
- if (! defined($state->{all_grp_paths_fwd}->{$grp_path})) {
- my $group = $state->{kdb}->add_group({
- title => $grp_path,
- icon => $DEfAULT_BAKUP_ICON,
- }); # root level group
- refresh_state_all_paths();
- $state->{kdb_has_changed}=1;
- }
- }
- if (! defined($state->{all_grp_paths_fwd}->{$grp_path})) {
- return undef;
- }
- return $state->{all_grp_paths_fwd}->{$grp_path};
-}
-
-# Helper function for cli_edit() and cli_clone()
-sub _entry_edit_gui($$$) {
- my $ent=shift @_;
- my $rChanges = shift @_;
- my $kdb_ver = shift @_;
- # Loop through the fields taking edits the user wants to make
- my @fields = get_entry_fields($kdb_ver);
- foreach my $input (@fields) {
- my $current_val = $ent->{$input->{key}};
- my $val = '';
- if (defined($input->{user_prep_func})) {
- $current_val = $input->{user_prep_func}($current_val);
- }
- if (defined($input->{user_edit_func})) {
- $val = $input->{user_edit_func}($ent, $input, $current_val);
- } elsif ($input->{multiline}) {
- $val = new_edit_multiline_input($input, $current_val);
- } else {
- $val = new_edit_single_line_input($input, $current_val);
- }
- # If the user hit ^C (SIGINT) then we need to stop
- if (recent_sigint()) { return -1; }
- # Call the validate_func if it's defined
- if (defined($input->{validate_func}) && length($val)) {
- # Note that $val can be modified by the validate_func
- if ($input->{validate_func}(\$val) != 0) {
- print "Invalid $input->{txt} input.\n";
- return -1;
- }
- }
- # Check a new title for same-name conflicts in its group
- if ($input->{key} eq 'title' && length($val)) {
- if ($val =~ m/\//) {
- print "kpcli cannot support titles with slashes (/) in them.\n";
- return -1;
- }
- my $path = $ent->{path}; # The group's path of the entry we are editing
- my $new_entry = normalize_path_string("$path/$val");
- if (defined($state->{all_ent_paths_fwd}->{$new_entry})) {
- print "An entry titled \"$val\" is already in $path.\n";
- return -1;
- }
- }
- # If the field was not empty, we'll change it to the new $val
- if (length($val)) { $rChanges->{$input->{key}} = $val; }
- }
- return 0;
-}
-
-
-# A code-consolidation function...
-sub get_prepped_readline_term() {
- our $state;
-
- # Note: A 2nd Term::ReadLine::Gnu->new() just returns the same
- # object as the one that Term::ShellUI is already using. With
- # Term::Readline::Perl, an attempt to call new a 2nd time results
- # in having a Term::Readline::Stub returned and a warning printed to
- # STDERR, which we also don't want, and so I coded this the way that
- # I did to make clear what is being done, and this comment for why.
- my $term = $state->{term}->{term}; # Term::ShellUI's readline
- $state->{active_readline} = $term;
-
- # Use Term::ReadLine::Gnu more advanced functionality...
- if ($term->ReadLine() eq 'Term::ReadLine::Gnu') {
- # Called roughly 10-times per second while Gnu readline waits for input
- $term->Attribs->{event_hook} = sub {
- if (recent_sigint()) {
- $term->Attribs->{event_hook} = undef;
- $term->set_prompt('');
- $term->Attribs->{line_buffer}='';
- $term->Attribs->{done} = 1;
- $state->{active_readline} = undef;
- }
- };
- }
-
- return $term;
-}
-
-# A helper function to interactively edit an entry's strings
-sub edit_entry_strings {
- my $ent = shift @_;
- my $input = shift @_;
- my $initial_value = shift @_;
- our $state;
-
- my $tmp_ent = clone($ent); # Clone the entity
- EDIT_INTERFACE: while (1) {
- my @strings_keys = ();
- if (defined($tmp_ent->{strings}) && ref($tmp_ent->{strings}) eq 'HASH') {
- @strings_keys = sort keys %{$tmp_ent->{strings}};
- }
- my $strings_count = scalar(@strings_keys);
- my $t='';
- my $prompt = "Strings";
- if ($strings_count > 0) {
- $t .= show_helper_files_strings($tmp_ent,{f=>1,v=>1},'strings');
- $prompt = "Choose";
- }
- $t .= "$prompt: (a)dd/(e)dit/(d)elete/(c)ancel/(F)inish? ";
- print "$t";
- COMMAND: while (my $key=get_single_key()) {
- if (lc($key) eq 'c' || ord($key) == 3) { # Cancel or ^C
- print "\n";
- return ''; # We are to return nothing on no change
- } elsif ($key =~ m/^[fF\r\n]$/) { # Finished (save)
- print "\n";
- return $tmp_ent->{strings};
- } elsif (lc($key) eq 'd') {
- if (defined($tmp_ent->{strings}) &&
- ref($tmp_ent->{strings}) eq 'HASH') {
- if ($strings_count < 2) {
- %{$tmp_ent->{strings}} = ();
- } else {
- print "\r". " "x60 ."\rWhich entry number do you want to delete? ";
- my $choice = get_single_line();
- if ($choice !~ m/^\d+$/ || $choice<1 || $choice > $strings_count) {
- print "\nInvalid number.";
- } else {
- delete($tmp_ent->{strings}->{$strings_keys[$choice-1]});
- }
- }
- print "\n";
- next EDIT_INTERFACE;
- }
- } elsif (lc($key) =~ m/^[ae]$/) {
- my $iv='';
- my $fld_being_edited = undef;
- if (lc($key) eq 'e') {
- print "\r". " "x60 ."\rWhich entry number do you want to edit? ";
- my $choice = get_single_line();
- if ($choice !~ m/^\d+$/ || $choice<1 || $choice > $strings_count) {
- print "\nInvalid number.";
- print "\n";
- next EDIT_INTERFACE;
- }
- $fld_being_edited = $strings_keys[$choice-1];
- my $val_being_edited = $tmp_ent->{strings}->{$fld_being_edited};
- $iv = "$fld_being_edited = $val_being_edited";
- }
- my $prompt = "Input a key=value pair: ";
- print "\r". " "x60 ."\r";
- my $term = get_prepped_readline_term();
- my $keyval = $term->readline($prompt, $iv);
- #my $keyval = get_single_line();
- if ($keyval =~ m/^\s*([^=]+?)\s*=\s*(.+?)\s*$/) {
- my ($fld, $val) = ($1, $2);
- if (!(defined($tmp_ent->{strings})) &&
- ref($tmp_ent->{strings}) eq 'HASH') {
- $tmp_ent->{strings} = {};
- }
- if (defined($tmp_ent->{strings}->{$fld}) &&
- (lc($key) eq 'a' || $fld ne $fld_being_edited)) {
- print "\nString field \"$fld\" already exists in the entry.";
- } else {
- $tmp_ent->{strings}->{$fld}=$val;
- if (defined($fld_being_edited) && $fld ne $fld_being_edited) {
- delete($tmp_ent->{strings}->{$fld_being_edited});
- }
- }
- } else {
- print "\nInvalid entry.";
- }
- print "\n";
- next EDIT_INTERFACE;
- } else {
- # Do nothing on invalid input
- next COMMAND;
- }
- }
- }
-
- return $initial_value;
-}
-# Single line input helper function for cli_new and cli_edit.
-sub new_edit_single_line_input($$) {
- my $input = shift @_;
- my $initial_value = shift @_;
- our $state;
-
- my $iv = ''; if (! $input->{hide_entry}) { $iv = $initial_value; }
- my $term = get_prepped_readline_term();
- my $val = '';
-
- PASSWD_COLLECTION: {
- if ($input->{genpasswd}) {
- print " "x25 .'("g" or "w" to auto-generate, "i" for interactive)'. "\r";
- }
- my $prompt=$input->{txt} . ': ';
- if ($input->{hide_entry}) {
- $val = GetPassword($prompt, '');
- } else {
- $val = $term->readline($prompt, $iv);
- }
- chomp $val;
- if ($input->{genpasswd} && $val =~ m/^([wgi])(\d*)$/) {
- my $cmd = $1;
- my $len = $2;
- if ($cmd eq 'g' || $cmd eq 'i') {
- if (!$len) {
- $len = $DEFAULT_PASSWD_LEN;
- } elsif ($len !~ m/^\d+$/ ||
- $len < $DEFAULT_PASSWD_MIN || $len > $DEFAULT_PASSWD_MAX) {
- $len = $DEFAULT_PASSWD_LEN;
- print color('yellow')
- . "Password length out of bounds, reset to $len chars.\n"
- . color('clear');
- }
- if ($cmd eq 'g') {
- $val = generatePasswordGobbledygook($len);
- } elsif ($cmd eq 'i') {
- $val = generatePasswordInteractive($len);
- # Interactive canceled by user
- if (! defined($val)) {
- print "\n";
- redo PASSWD_COLLECTION;
- }
- }
- } elsif ($cmd eq 'w') {
- $val = generatePassword();
- } else {
- die "BUG: it should be impossible to get to this code!\n";
- }
- } elsif (length($val) && $input->{double_entry_verify}) {
- my $prompt = "Retype to verify: ";
- my $checkval = '';
- if ($input->{hide_entry}) {
- $checkval = GetPassword($prompt, '');
- } else {
- $checkval = $term->readline($prompt);
- }
- # If the user hit ^C (SIGINT) then we need to stop
- if (recent_sigint()) { return undef; }
- chomp $checkval;
- if ($checkval ne $val) {
- print "Entries mismatched. Please try again.\n";
- redo PASSWD_COLLECTION;
- }
- }
- }
- # We are done with readline calls so let's cleanup.
- if ($term->ReadLine() eq 'Term::ReadLine::Gnu') {
- $term->Attribs->{startup_hook} = undef;
- $term->Attribs->{event_hook} = undef;
- }
- # This funciton is supposed to return blank if nothing changed
- if ($val eq $initial_value) { $val = ''; }
- return $val;
-}
-# Multi-line input helper function for cli_new and cli_edit.
-sub new_edit_multiline_input($$) {
- my $input = shift @_;
- my $initial_value = shift @_;
- our $state;
-
- # Protect programmers from accidentally misusing this function
- if ($input->{genpasswd}) { die "genpasswd unsupported by this function"; }
-
- if ($input->{hide_entry}) {
- print $input->{txt} . ": ";
- } else {
- my $mlval = $initial_value;
- if ($mlval =~ m/\r|\n/) { $mlval = "\n$mlval\n"; }
- print $input->{txt} . " (\"".$mlval."\"): ";
- }
-
- my $yellow=color('yellow');
- my $clear=color('clear');
- print "\n$yellow(end multi-line input with a single \".\" on a line)$clear\n";
-
- my $term = get_prepped_readline_term();
-
- my $val = ''; my $unfinished = 1;
- while ($unfinished) {
- # Term::ReadLine::Perl seems to have a bug that requires a prompt
- # (it borks on ''), but after adding the pipe I liked it so left
- # it place for all readlines.
- my $line = $term->readline('| ');
- if ($line =~ m/^\.[\r\n]*$/) { # a lone "." ends our input
- $unfinished = 0;
- } else {
- $val .= $line;
- $val .= "\n" if ($val !~ m/\n$/); # ReadLine() vs. $term->readline().
- if ($val =~ m/^[\r\n]*$/) { $val = ''; $unfinished = 0; }
- }
- # If the user hit ^C (SIGINT) then we need to stop
- if (recent_sigint()) { return undef; }
- }
- chomp($val); # Remove extra line at the end
- return $val;
-}
-
-# Formats an entry for display for cli_show()
-sub show_format($$) {
- my $title=shift @_;
- my $value=shift @_;
- my $val=$value;
- if ($val =~ m/\n/) {
- my @val_lines=split(/\n/, $val);
- $val=join("\n" . " "x(5+2), @val_lines);
- }
- return sprintf("%5s: %s", $title,$val);
-}
-
-# Formats a statistic display for cli_stats()
-sub stats_print($) {
- my $stats_r = shift @_;
-
- my $max_key_len = 0;
- my $max_val_len = 0;
- foreach my $k (keys(%{$stats_r})) {
- if (length($k) > $max_key_len) { $max_key_len = length($k); }
- }
- foreach my $k (values(%{$stats_r})) {
- if (length($k) > $max_val_len) { $max_val_len = length($k); }
- }
- my $sprintf_format = " - %$max_key_len" . "s: %$max_val_len" . "d\n";
- my $t='';
- foreach my $stat_key (sort {$a <=> $b} keys %{$stats_r}) {
- $t .= sprintf($sprintf_format, $stat_key, $stats_r->{$stat_key});
- }
- return $t;
-}
-
-# Helper functions to normalize and humanize entry tags
-sub normalize_entry_tags {
- my $rTagline = shift @_;
- my @tags = split(/\s*[;,]\s*/, $$rTagline);
- $$rTagline = join(';', @tags);
- return 0;
-}
-sub humanize_entry_tags {
- my $tagline = shift @_;
- my @tags = split(/\s*[;,]\s*/, $tagline);
- return join(', ', @tags);
-}
-
-# Function to return a data structure to support adding/editing entries
-sub get_entry_fields {
- my $kdb_ver = shift @_ || 1; # *.kdb or *.kdbx (v1 or v2)
- my @fields = (
- { key=>'title', txt=>'Title' },
- { key=>'username', txt=>'Username' },
- { key=>'password', txt=>'Password',
- hide_entry => 1, double_entry_verify => 1, genpasswd => 1 },
- { key=>'url', txt=>'URL' },
- );
- # We only want "tags" if we are dealing with a v2 file (*.kdbx)
- if ($kdb_ver == 2) {
- push @fields, (
- { key=>'tags', txt=>'Tags', 'multiline' => 0,
- user_prep_func => \&humanize_entry_tags,
- validate_func => \&normalize_entry_tags,
- },
- { key=>'strings', txt=>'Strgs',
- user_edit_func => \&edit_entry_strings,
- },
- );
- }
- push @fields, (
- { key=>'comment', txt=>'Notes/Comments', 'multiline' => 1 },
- );
- return @fields;
-}
-
-# Code consolidation function.
-# Returns true if $state->{signals}->{INT} indicates a SIGINT more
-# recently than the $timeframe given, which defaults to 0.25 secs.
-sub recent_sigint() {
- my $timeframe = shift @_;
- if ($timeframe !~ m/^([0-9]*\.[0-9]+|[0-9]+)$/) { $timeframe = 0.25; }
- our $state;
- if (defined($state->{signals}->{INT}) &&
- tv_interval($state->{signals}->{INT}) < $timeframe) {
- return 1;
- }
- return 0;
-}
-
-sub cli_icons($) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- print "Change icons on Groups or Entries (g/e/Cancel)? ";
- my $groups_or_entries=lc(get_single_key());
- print "\n";
- if (recent_sigint() || $groups_or_entries !~ m/^[ge]$/) { return; }
-
- print "Change icons Here, Below here, or Globally (h/b/g/Cancel)? ";
- my $glob_or_rel=lc(get_single_key());
- print "\n";
- if (recent_sigint() || $glob_or_rel !~ m/^[hgb]$/) { return; }
-
- print "What would you line the new icon to be (0-64/Cancel)? ";
- my $term = get_prepped_readline_term();
- my $val = $term->readline('');
- # If the user hit ^C (SIGINT) then we need to stop
- if (recent_sigint()) { return undef; }
- chomp($val);
- if ($val !~ m/^[0-9]+$/ || $val < 0 || $val > 64) {
- print "Invalid icon number.\n";
- return;
- }
-
- # This code fills @{$groups} or @{$entries} with the items that the
- # user wants to change the icons on.
- my ($groups,$entries) = ([],[]);
- if ($glob_or_rel eq 'h') { # "Here" is easy -- we have a function for that.
- ($groups,$entries) = get_current_groups_and_entries();
- if ($groups_or_entries eq 'e') {
- $groups = [];
- } else {
- $entries = [];
- }
- } else {
- if ($glob_or_rel eq 'g') { # Globally is easy, it's all groups
- my $k=$state->{kdb};
- @{$groups} = $k->find_groups({});
- } elsif ($glob_or_rel eq 'b') {
- my $id=$state->{path}->{id};
- @{$groups} = all_child_groups_flattened($id); # *only child groups*
- } else {
- warn "WHAT? Should never get to this piece of code!\n";
- }
- # If the user wanted to operate on entries, collect all the entries
- # in the @{$groups} and then empty @{$groups}.
- if ($groups_or_entries eq 'e') {
- foreach my $group (@{$groups}) {
- if (defined($group->{entries})) {
- push @{$entries}, @{$group->{entries}};
- }
- }
- $groups = [];
- }
- }
-
- # Change the items, recording the number of changes.
- my $items_changed=0;
- foreach my $item (@{$groups}, @{$entries}) {
- $item->{icon} = $val;
- $items_changed++;
- }
-
- # Tell the user what we did.
- print "The icon value was set to $val on $items_changed records.\n";
-
- # If we changed anything, ask the user if they want to save
- if ($items_changed > 0) {
- $state->{kdb_has_changed}=1;
- RequestSaveOnDBChange();
- }
- return 0;
-}
-
-sub cli_new($) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
- return;
- }
-
- my $new_path=get_pwd();
- my $new_title='';
- # If the user gave us a path (that may include the new title) in args[0],
- # then we have to first rationalize that path.
- if (defined($params->{args}->[0])) {
- $new_path = $params->{args}->[0];
- # Insure that $new_path is absolute
- if ($new_path !~ m/^\/+$/) {
- $new_path = get_pwd() . '/' . $new_path;
- }
- my $norm_path = normalize_path_string($new_path);
- my ($grp_path,$name)=normalize_and_split_raw_path($new_path);
- if (defined($state->{all_grp_paths_fwd}->{$norm_path})) {
- $new_path = '/' . humanize_path($norm_path);
- $new_title = '';
- } elsif (defined($state->{all_grp_paths_fwd}->{$grp_path})) {
- $new_path = '/' . humanize_path($grp_path);
- $new_title = $name;
- } else {
- if ($norm_path eq '') {
- print "Entries cannot be made in the root path.\n";
- } else {
- print "Bad path for new entry\n";
- }
- return;
- }
- }
-
- # $id needs to be set to the ID of the group we want to add to
- my $id = undef;
- my $norm_path=normalize_path_string($new_path);
- if (defined($state->{all_grp_paths_fwd}->{$norm_path})) {
- $id=$state->{all_grp_paths_fwd}->{$norm_path};
- }
-
- # Make sure that we have a valid path for creating a new entry
- if ($new_path =~ m/^\/+$/ || (! defined($id))) {
- print "Entries cannot be made in this path ($new_path).\n";
- return -1;
- }
-
- print "Adding new entry to \"$new_path\"\n";
-
- # Grab the entries at this $id (pwd) so we can check for conflicts
- my $k=$state->{kdb};
- my ($this_grp, at trash) = $k->find_groups({id=>$id});
- my @entries = $k->find_entries({group_id => $id});
-
- my $new_entry = {
- 'group' => $id,
- };
-
- my @fields = get_entry_fields($state->{kdb_ver});
- NEW_DATA_COLLECTION: foreach my $input (@fields) {
- my $val = '';
- if (defined($input->{user_edit_func})) {
- $val = $input->{user_edit_func}($new_entry, $input, {});
- # An empty hash-ref is "empty/blank" for strings, but the
- # user_edit_func returns the scalar '' in that case.
- if ($input->{key} eq 'strings' && ref($val) ne 'HASH') { $val = {}; }
- } elsif ($input->{multiline}) {
- $val = new_edit_multiline_input($input, '');
- } else {
- if ($new_title ne '' && $input->{key} eq 'title') {
- print $input->{txt} . ": $new_title\n";
- $val = $new_title;
- } else {
- $val = new_edit_single_line_input($input, '');
- }
- }
- # If the user hit ^C, abort the new entry
- if (recent_sigint()) { return undef; }
- # If the user gave us an empty title, abort the new entry
- if ($input->{key} eq 'title' && length($val) == 0) { return undef; }
- # Call the validate_func if it's defined
- if (defined($input->{validate_func}) && length($val)) {
- # Note that $val can be modified by the validate_func
- if ($input->{validate_func}(\$val) != 0) {
- print "Invalid $input->{txt} input.\n";
- return -1;
- }
- }
- # Check the new title for same-name conflicts in its group
- if ($input->{key} eq 'title') {
- if ($val =~ m/\//) {
- print "kpcli cannot support titles with slashes (/) in them.\n";
- return undef;
- }
- my $new_entry = normalize_path_string($new_path . '/' . $val);
- if (defined($state->{all_ent_paths_fwd}->{$new_entry})) {
- print "An entry titled \"$val\" is already in $new_path.\n";
- return undef;
- }
- }
-
- $new_entry->{$input->{key}} = $val;
- }
- $new_entry->{icon} = $DEFAULT_ENTRY_ICON;
-
- $k->unlock;
- my $new_entry_ref = $k->add_entry($new_entry);
- $k->lock;
- if ($new_entry_ref->{id}) {
- refresh_state_all_paths();
- $state->{kdb_has_changed}=1;
- RequestSaveOnDBChange();
- } else {
- print "Failed to add new entry.\n";
- }
- return 0;
-}
-
-sub cli_import {
- my $file=shift @_;
- my $new_group_path=shift @_;
- my $key_file=shift @_;
- our $state;
-
- if (recent_sigint() || deny_if_readonly()) { return; }
-
- # If the user gave us a bogus file there's nothing to do
- if (! -f ($file)) {
- print "File does not exist: $file\n";
- return -1;
- }
- if (magic_file_type($file) ne 'keepass') {
- print "Does not appear to be a KeePass file: $file\n";
- return -1;
- }
- # If the $new_group_path is a relative path, make it absolute
- if ($new_group_path !~ m/^\//) {
- $new_group_path = get_pwd() . "/$new_group_path";
- }
- # We won't import into an existing group
- my $full_path=normalize_path_string($new_group_path);
- if (defined($state->{all_grp_paths_fwd}->{$full_path})) {
- print "You must specify a _new_ group to import into.\n";
- return -1;
- }
- # Make sure the new group's parent exists
- my ($grp_path,$grp_name)=normalize_and_split_raw_path($new_group_path);
- if ($grp_path != '' && ! defined($state->{all_grp_paths_fwd}->{$grp_path})) {
- print "Path does not exist: /" . humanize_path($grp_path) . "\n";
- return -1;
- }
- # Set the $parent_group value appropriately
- my $parent_group = undef; # Root by default
- if (length($grp_path)) {
- $parent_group = $state->{all_grp_paths_fwd}->{$grp_path};
- }
- # Ask the user for the master password and then open the kdb
- my $master_pass=GetMasterPasswd();
- if (recent_sigint()) { return undef; } # Bail on SIGINT
- my $iKDB = File::KeePass->new;
- if (! eval { $iKDB->load_db($file,
- composite_master_pass($master_pass, $key_file)) }) {
- print "Couldn't load the file $file: $@\n";
- return -1;
- }
- # Add the new group, to its parent or to root if $parent_group==undef
- my $k=$state->{kdb};
- my $new_group=$k->add_group({
- title => $grp_name,
- group => $parent_group,
- });
- # Copy the $iKDB into our $k at $new_group
- $iKDB->unlock();
- $k->unlock();
- my @root_groups = $iKDB->find_groups({level=>0});
- foreach my $i_root_grp (@root_groups) {
- copy_kdb_group_tree($k,$i_root_grp,$new_group);
- }
- $k->lock();
- $iKDB->lock();
- $iKDB=undef;
- # Refresh all paths and mark state as changed
- refresh_state_all_paths();
- $state->{kdb_has_changed}=1;
- RequestSaveOnDBChange();
-}
-
-sub cli_export($$) {
- my $file=shift @_;
- my $key_file=shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- # Warn is we are being asked to overwrite a file
- if (-e $file) {
- print "WARNING: $file already exists.\n" .
- "Overwrite it? [y/N] ";
- my $key=get_single_key();
- print "\n";
- if (lc($key) ne 'y') {
- return -1;
- }
- }
-
- # Ask if the key file should be generated, if it doesn't exist
- my $make_keyfile=0;
- if (defined($key_file) && (! -f $key_file)) {
- print "Your specified key file does not exist. Generate it? [y/N] ";
- my $key=get_single_key();
- print "\n";
- if (lc($key) ne 'y') {
- return -1;
- }
- $make_keyfile=1;
- if (recent_sigint()) { return undef; } # Bail on SIGINT
- }
-
- # Get the master password for the exported file
- my $master_pass=GetMasterPasswdDoubleEntryVerify();
- if (recent_sigint()) { return undef; } # Bail on SIGINT
- if (! defined($master_pass)) { return undef; }
- if (length($master_pass) == 0) {
- print "For your safety, empty passwords are not allowed...\n";
- return;
- }
-
- # Generate the key file if so instructed
- if (defined($key_file) && $make_keyfile) {
- my $fh=new FileHandle;
- if (! open($fh,'>',$key_file)) {
- print "ERROR: Could not open key file for writing: $key_file\n";
- return -1;
- }
- my $KEYFILE_SIZE = 2048;
- print $fh generateKeyfileContents($KEYFILE_SIZE);
- close($fh);
- my $size = (stat($key_file))[7];
- if ($size != $KEYFILE_SIZE) {
- print "ERROR: generated key file is the wrong size: $key_file\n";
- return -1;
- }
- }
-
- # Build the new kdb in RAM
- my $k=$state->{kdb};
- my $new_kdb=new File::KeePass;
- $k->unlock; # Required so that we can copy the passwords
- if (get_pwd() ne '/') {
- # Grab the root group's $id at our pwd
- my $pwd_group_id=$state->{path}->{id};
- my ($root_grp, at trash) = $k->find_groups({id=>$pwd_group_id});
- copy_kdb_group_tree($new_kdb,$root_grp,undef);
- } else {
- # Put all of the root groups into the new file (entire file copy)
- my @root_groups = $k->find_groups({level=>0});
- foreach my $root_grp (@root_groups) {
- copy_kdb_group_tree($new_kdb,$root_grp,undef);
- }
- }
- $k->lock;
- $new_kdb->unlock;
- my $new_db_bin =
- $new_kdb->gen_db(composite_master_pass($master_pass,$key_file));
- $new_kdb->lock;
-
- # Test parsing the kdb from RAM (we'll most likely die if this fails)
- my $new_db=new File::KeePass;
- $new_db->parse_db($new_db_bin,composite_master_pass($master_pass,$key_file));
-
- # Now write the new kdb to disk
- my $fh=new FileHandle;
- if (open($fh,'>',$file)) {
- print $fh $new_db_bin;
- close $fh;
- print "Exported to $file\n";
- } else {
- print "Could not open \"$file\" for writing.\n";
- }
-
- return 0;
-}
-
-# A helper function for cli_export() and cli_import(). It takes a kdb object,
-# a group as a starting point to copy from, and optionally a parent_group to
-# copy to. It copies everything from the source group's root downward. In our
-# use cases, the _target_ $kdb object passed in here is typically a different
-# one than the _source_ $group is from.
-sub copy_kdb_group_tree {
- my $kdb=shift @_;
- my $group=shift @_;
- my $parent_group=shift @_ || undef; # When undef, it writes to the root
-
- # Add the new group, to it's parent or root if $parent_group==undef
- my $new_group=$kdb->add_group({
- title => $group->{title},
- icon => $group->{icon},
- id => $group->{id},
- group => $parent_group,
- });
-
- # Add the new_group's entries
- if (ref($group->{entries}) eq 'ARRAY') {
- foreach my $entry (@{$group->{entries}}) {
- $entry->{group} = $new_group;
- $kdb->add_entry($entry);
- }
- }
-
- # Add the new_group's child groups
- if (ref($group->{groups}) eq 'ARRAY') {
- foreach my $child_grp (@{$group->{groups}}) {
- copy_kdb_group_tree($kdb,$child_grp,$new_group);
- }
- }
-}
-
-sub cli_saveas($) {
- my $file=shift @_;
- my $key_file=shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- # If the user has asked for a *.kdbx file, check the File::KeePass version
- if (version->parse($File::KeePass::VERSION) < version->parse('2.03')) {
- if ($file =~ m/\.kdbx$/i) {
- print "KeePass v2 (*.kdbx files) require File::KeePass >= v2.03\n";
- return;
- }
- }
-
- # Warn is we are being asked to overwrite a file
- if (-e $file) {
- print "WARNING: $file already exists.\n" .
- "Overwrite it? [y/N] ";
- my $key=get_single_key();
- print "\n";
- if (lc($key) ne 'y') {
- return -1;
- }
- if (recent_sigint()) { return undef; } # Bail on SIGINT
- }
-
- # Ask if the key file should be generated, if it doesn't exist
- my $make_keyfile=0;
- if (defined($key_file) && (! -f $key_file)) {
- print "Your specified key file does not exist. Generate it? [y/N] ";
- my $key=get_single_key();
- print "\n";
- if (lc($key) ne 'y') {
- return -1;
- }
- $make_keyfile=1;
- if (recent_sigint()) { return undef; } # Bail on SIGINT
- }
-
- # Get the master password for the file
- my $master_pass=GetMasterPasswdDoubleEntryVerify();
- if (recent_sigint()) { return undef; } # Bail on SIGINT
- if (! defined($master_pass)) { return undef; }
- if (length($master_pass) == 0) {
- print "For your safety, empty passwords are not allowed...\n";
- return;
- }
-
- # Generate the key file if so instructed
- if (defined($key_file) && $make_keyfile) {
- my $fh=new FileHandle;
- if (! open($fh,'>',$key_file)) {
- print "ERROR: Could not open key file for writing: $key_file\n";
- return -1;
- }
- my $KEYFILE_SIZE = 2048;
- print $fh generateKeyfileContents($KEYFILE_SIZE);
- close($fh);
- my $size = (stat($key_file))[7];
- if ($size != $KEYFILE_SIZE) {
- print "ERROR: generated key file is the wrong size: $key_file\n";
- return -1;
- }
- }
-
- destroy_found();
- scrub_unknown_values_from_all_groups(); # TODO - remove later
- $state->{kdb}->unlock;
- $state->{kdb}->save_db($file,composite_master_pass($master_pass,$key_file));
- $state->{kdb}->lock;
-
- $state->{kdb}= File::KeePass->new;
- if (! eval { $state->{kdb}->load_db($file,
- composite_master_pass($master_pass,$key_file)) }) {
- die "Couldn't load the file $file: $@";
- }
- $state->{kdb_has_changed}=0;
- $state->{kdb_file} = $file;
- $state->{key_file} = $key_file;
- $state->{put_master_passwd}($master_pass);
- $master_pass="\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
- return 0;
-}
-
-# This routine takes a raw (directly from user input) path string
-# and "normalizes" it (converts it to NULL-separated w/out escapes)
-# and splits it into its dirname and basename components.
-sub normalize_and_split_raw_path($) {
- my $raw_pathstr=shift @_;
-
- my $path=normalize_path_string($raw_pathstr);
-
- my $basename='';
- if ($path =~ m/\0/) { # case of at least one path delimeter
- $path =~ s/\0([^\0]+)$//;
- $basename=$1;
- } elsif ($raw_pathstr =~ m/^\//) { # case of simple "/foo" (no subdir)
- $basename=$path;
- $path='';
- } else { # case of simple "foo" (no path delims)
- $path = normalize_path_string(get_pwd());
- $basename=$raw_pathstr;
- }
-return($path,$basename);
-}
-
-sub cli_rmdir($) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
- return;
- }
-
- my $raw_pathstr=$params->{'args'}->[0];
- my ($path,$grp_name) = normalize_and_split_raw_path($raw_pathstr);
-
- # Make sure the group exists.
- my $grp_path="$path\0$grp_name";
- $grp_path=~s/^\0+//;
- if (! defined($state->{all_grp_paths_fwd}->{$grp_path})) {
- print "Path does not exist: /" . humanize_path($grp_path) . "\n";
- return -1;
- }
-
- my $group_id = $state->{all_grp_paths_fwd}->{$grp_path};
- my $group = $state->{kdb}->find_group({ id => $group_id });
- my @entries = ();
- my $entry_cnt=0;
- if (defined($group->{entries})) {
- @entries = grep(m/^\Q$grp_path\E\0/,keys %{$state->{all_ent_paths_fwd}});
- $entry_cnt = scalar(@entries);
- }
- my $group_cnt=0;
- if (defined($group->{entries})) {
- $group_cnt=
- scalar(grep(m/^\Q$grp_path\E\0/,keys %{$state->{all_grp_paths_fwd}}));
- }
- my $child_cnt=$entry_cnt + $group_cnt;
- if ( $child_cnt > 0) {
- print "WARNING: This group has $child_cnt child groups and/or entries.\n" .
- "Really remove it!? [y/N] ";
- my $key=get_single_key();
- print "\n";
- if (lc($key) ne 'y') {
- return -1;
- }
- }
-
- # Recycle any entries that we're also removing
- if ($entry_cnt > 0) {
- if (! (defined($opts->{'no-recycle'}) && int($opts->{'no-recycle'}))) {
- foreach my $ent_path (@entries) {
- my $ent_id = $state->{all_ent_paths_fwd}->{$ent_path};
- my $ent = $state->{kdb}->find_entry({id => $ent_id});
- my $errmsg = recycle_entry($state, $ent);
- if (defined($errmsg)) { print "WARNING: $errmsg\n"; }
- }
- }
- }
-
- # Delete the group, refresh our state paths, request save, etc.
- my $deleted_group = $state->{kdb}->delete_group({ id => $group_id });
- refresh_state_all_paths();
- $state->{kdb_has_changed}=1;
- RequestSaveOnDBChange();
- return 0;
-}
-
-sub cli_mkdir($) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
- return;
- }
-
- my $raw_pathstr = $params->{args}->[0];
- my ($path,$newdir) = normalize_and_split_raw_path($raw_pathstr);
-
- # Make sure the group doesn't already exist.
- my $newdir_path="$path\0$newdir";
- $newdir_path=~s/^\0+//;
- if (defined($state->{all_grp_paths_fwd}->{$newdir_path})) {
- print "Path already exists: /" . humanize_path($newdir_path) . "\n";
- return -1;
- }
-
- # Create the group
- my $group='';
- if ($path eq '') {
- $group = $state->{kdb}->add_group({
- title => $newdir,
- icon => $DEfAULT_GROUP_ICON,
- }); # root level group
- } elsif (defined($state->{all_grp_paths_fwd}->{$path})) {
- my $group_id=$state->{all_grp_paths_fwd}->{$path};
- $group = $state->{kdb}->add_group({
- title => $newdir,
- group => $group_id,
- icon => $DEfAULT_GROUP_ICON,
- });
- } else {
- print "Cannot make directory at path " . humanize_path($path) . "\n";
- return -1;
- }
-
- # Because we created a new group we need to refresh ours state paths
- refresh_state_all_paths();
- $state->{kdb_has_changed}=1;
- RequestSaveOnDBChange();
- return 0;
-}
-
-sub humanize_path($) {
- my $path=shift @_;
- $path =~ s/\//\\\//g;
- $path =~ s/\0/\//g;
- return $path;
-}
-
-sub cli_open($) {
- my $path=shift @_;
- my $key=shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- # If cli_close() does not return 0 the user decided not to close the file
- if (cli_close() != 0) {
- return -1;
- }
-
- if ( -f $path ) {
- my $err = open_kdb($path, $key);
- if (length($err)) {
- print "Error opening file: $err\n";
- }
- } else {
- print "Cannot open: $path\n";
- }
-}
-
-# Get a single keypress from the user
-sub get_single_key {
- my $drain_first = shift @_ || 1;
- our $state;
- my $key='';
- $|=1; # Needed to flush STDOUT on Windows cmd prior to calling ReadMode
- ReadMode('raw'); # Turn off controls keys
- while($drain_first && defined( $key = ReadKey(-1) ) ) {} # Drain STDIN first
- while (not defined ($key = ReadKey(-1))) {
- # If the user hit ^C (SIGINT) then we need to stop
- if (recent_sigint()) { return undef; }
- # No key yet, but let's not eat 100% CPU while waiting, so sleep.
- Time::HiRes::sleep(0.1);
- }
- ReadMode('restore');
-return $key;
-}
-# Get a single line of input from the user
-sub get_single_line {
- our $state;
- $|=1; # Needed to flush STDOUT on Windows cmd prior to calling ReadMode
- my $input='';
- while (1) {
- my $c = get_single_key(0);
- if (ord($c) == 3) { # ^C
- return '';
- } elsif (ord($c) == 127 || ord($c) == 8) { # backspace (Linux/Windows)
- if (length($input) > 0) {
- $input = substr($input,0,length($input) - 1);
- print chr(8)." ".chr(8); # Erase a character
- }
- } elsif ($c =~ m/[\n\r]/) {
- return $input;
- } else {
- $input.=$c;
- print $c;
- }
- }
-}
-
-sub cli_close {
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- if ($state->{kdb_has_changed}) {
- print "WARNING: The database has changed and was not saved.\n" .
- "Really close it? [y/N] ";
- my $key=get_single_key();
- print "\n";
- if (lc($key) ne 'y') {
- return -1;
- }
- }
-
- $state->{'kdb'}->clear();
- new_kdb($state);
- return 0;
-}
-
-# This sets $state to a brand new, KeePassX-style, empty, unsaved database
-sub new_kdb {
- my $state=shift @_;
- $state->{kdb_has_changed}=0;
- $state->{'kdb'} = File::KeePass->new;
- #$state->{kdb_ver} = $state->{kdb}->{header}->{version}; # undef after ->new()
- $state->{kdb_ver} = 1; # Only provide 1.x (*.kdb) features by default
- # To be compatible with KeePassX
- $state->{'kdb'}->add_group({ title => 'eMail' });
- $state->{'kdb'}->add_group({ title => 'Internet' });
- refresh_state_all_paths();
- if (-f $state->{placed_lock_file}) { unlink($state->{placed_lock_file}); }
- delete($state->{placed_lock_file});
- delete($state->{kdb_file});
- delete($state->{key_file});
- delete($state->{master_pass});
- cli_cd($term, {'args' => ["/"]});
-}
-
-sub cli_ls($$) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- my @paths = ();
- if (defined($params) && defined($params->{'args'}) &&
- ref($params->{'args'}) eq 'ARRAY') {
- @paths = @{$params->{'args'}};
- }
- if (scalar(@paths) == 0) { push @paths, ''; }
- my $paths_count = scalar(@paths);
- my $loops=0;
- foreach my $path (@paths) {
- # If multiple dirs are being listed, title each
- if ($paths_count > 1) {
- $path =~ s/\/+$//; print "$path/:\n";
- }
-
- # If we were given a path, use cli_cd() to go there temporarily...
- my $old_path='';
- if (length($path)) {
- $old_path=get_pwd();
- if (cli_cd($term, {'args' => [$path]})) {
- return -1; # If cli_cd() returned non-zero it failed
- }
- }
-
- # List the pwd
- $state->{last_ls_path} = get_pwd();
- my ($rGrps,$rEnts) = get_current_groups_and_entries();
- if (scalar(@{$rGrps}) > 0) {
- print "=== Groups ===\n";
- print join("\n", @{get_human_group_list($rGrps)}) . "\n";
- }
- if (scalar(@{$rEnts}) > 0) {
- print "=== Entries ===\n";
- print join("\n", @{get_human_entry_list($rEnts)}) . "\n";
- }
-
- # If we temporarily cd'ed, cd back.
- if (length($old_path)) {
- cli_cd($term, {'args' => [$old_path]});
- }
-
- # If printing multiple dirs, we need to append a \n to all but the last
- if ($paths_count > 1 && ++$loops < $paths_count) {
- print "\n";
- }
- }
- return 0;
-}
-
-# Helper function for cli_ls()
-sub get_human_group_list($) {
- my $rGroup=shift @_;
- my @list=();
- foreach my $grp (@{$rGroup}) {
- #push (@list, sprintf("%15d %s/", $grp->{id}, $grp->{title}));
- push (@list, "$grp->{title}/");
- push (@list, &Dumper($grp)) if ($DEBUG > 2);
- }
- return \@list;
-}
-
-# Helper function for cli_ls()
-sub get_human_entry_list($) {
- my $rEntries=shift @_;
- my @list=();
- my $i=0;
- my $d_len=int((scalar(@{$rEntries}) - 1) / 10) + 1;
- foreach my $ent (@{$rEntries}) {
- my $url=$ent->{url};
- $url=~s/^https?:\/\///i;
- $url=~s/\/+$//;
- push (@list, sprintf("%".$d_len."d. %-40.40s %30.30s",
- $i, $ent->{title}, $url));
- $i++;
- }
- return \@list;
-}
-
-# Routine to hook into Term::ShellUI's exit on Ctrl-D functionality
-sub eof_exit_hook {
- our $state;
- # We need a newline if cli_quit() will talk tothe user about saving
- if ($state->{kdb_has_changed}) { print "\n"; }
- # cli_quit() will handle user interaction and return a value for
- # the exit_hook of Term::ShellUI.
- return cli_quit($state->{term},undef);
-}
-
-# Entry attachment handling
-sub cli_attach {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
- return;
- }
-
- my $target = $params->{args}->[0];
- my $ent=find_target_entity_by_number_or_path($target);
- if (! defined($ent)) {
- print "Don't see an entry at path: $target\n";
- return -1;
- }
-
- my $tmp_ent = clone($ent); # Clone the entity
- $state->{attach_changed} = 0;
- ATTACH_INTERFACE: while (1) {
- my @strings_keys = ();
- if (defined($tmp_ent->{binary}) && ref($tmp_ent->{binary}) eq 'HASH') {
- @strings_keys = sort keys %{$tmp_ent->{binary}};
- }
- my $strings_count = scalar(@strings_keys);
- my $t='';
- my $prompt = "Choose";
- if ($strings_count > 0) {
- $t .= show_helper_files_strings($tmp_ent,{f=>1,v=>1},'binary');
- $t .= "$prompt: (a)dd/(e)xport/(d)elete/(c)ancel/(F)inish? ";
- } else {
- $t .= "No files attached.\n";
- $t .= "$prompt: (a)dd/(c)ancel/(F)inish? ";
- }
- print "$t";
- COMMAND: while (my $key=get_single_key()) {
- if (lc($key) eq 'c' || ord($key) == 3) { # Cancel or ^C
- print "\n";
- delete $state->{attach_changed}; # Delete our temporary state var
- return;
- } elsif ($key =~ m/^[fF\r\n]$/) { # Finished (save)
- print "\n";
- $ent->{binary} = $tmp_ent->{binary};
- if ($state->{attach_changed}) {
- delete $state->{attach_changed}; # Delete our temporary state var
- # Recycle the entry if changes were made unless --no-recycle
- if (!(defined($opts->{'no-recycle'}) && int($opts->{'no-recycle'}))) {
- my $errmsg = recycle_entry($state, $ent);
- if (defined($errmsg)) { print "WARNING: $errmsg\n"; }
- }
- $state->{kdb_has_changed} = 1;
- RequestSaveOnDBChange();
- }
- return;
- } elsif (lc($key) eq 'd') {
- if (defined($tmp_ent->{binary}) && ref($tmp_ent->{binary}) eq 'HASH') {
- if ($strings_count < 2) {
- %{$tmp_ent->{binary}} = ();
- $state->{attach_changed}=1 if ($strings_count == 1);
- } else {
- print "\r". " "x60 ."\rWhich entry number do you want to delete? ";
- my $choice = get_single_line();
- if ($choice !~ m/^\d+$/ || $choice<1 || $choice > $strings_count) {
- print "\nInvalid number.";
- } else {
- delete($tmp_ent->{binary}->{$strings_keys[$choice-1]});
- $state->{attach_changed}=1;
- }
- }
- print "\n";
- next ATTACH_INTERFACE;
- }
- } elsif (lc($key) eq 'e') { # export
- my $to_export = undef;
- if (defined($tmp_ent->{binary}) && ref($tmp_ent->{binary}) eq 'HASH') {
- if ($strings_count < 1) {
- print "\n" .color('yellow'). 'Nothing it attached to export...' .
- color('clear')."\n";
- } elsif ($strings_count == 1) {
- $to_export = $strings_keys[0];
- } else {
- print "\r". " "x60 ."\rWhich entry number do you want to export? ";
- my $choice = get_single_line();
- if ($choice !~ m/^\d+$/ || $choice<1 || $choice > $strings_count) {
- print "\nInvalid number.";
- } else {
- $to_export = $strings_keys[$choice-1];
- }
- }
- }
- if (defined($to_export) && defined($tmp_ent->{binary}->{$to_export})) {
- my $homedir=get_user_homedir();
- my $filename = $to_export;
- my @path = File::Spec->splitdir($homedir);
- my $iv = File::Spec->catfile(@path, $filename); # homedir/filename
- print "\n";
- my $filename = prompt_filename_from_user($self,"Path to file: ",$iv);
- if (! length($filename)) { next ATTACH_INTERFACE; }
- if (lc($OSNAME) !~ m/^mswin/) { $filename=expand_tildes($filename); }
- # If we're given a directory, assume the user wants to write into it
- if (-e -d $filename) { $filename .= '/' . $to_export; }
- if (-e -f $filename) {
- print color('yellow'). "WARNING: file already exists: $filename\n" .
- "Overwrite it? [y/N] " .color('clear');
- my $key=get_single_key();
- print "\n";
- if (lc($key) ne 'y') {
- next ATTACH_INTERFACE;
- }
- }
- my $fh = new FileHandle;
- if (! open($fh,'>', $filename)) {
- print "ERROR: cannot write to: $filename\n";
- next ATTACH_INTERFACE;
- }
- print $fh $tmp_ent->{binary}->{$to_export};
- close $fh;
- print "Saved to: $filename\n";
- }
- next ATTACH_INTERFACE;
- } elsif (lc($key) eq 'a') { # add
- if ($strings_count > 0 && $state->{kdb_ver} == 1) {
- print "\n" .color('yellow').
- 'KeePass v1 files support only one attachment per entry.' .
- color('clear')."\n";
- next ATTACH_INTERFACE;
- }
- print "\n";
- my $filename = prompt_filename_from_user($self, "Path to file: ", "");
- if (! length($filename)) { next ATTACH_INTERFACE; }
- my $errmsg = do_attach_file($tmp_ent, $filename);
- if (defined($errmsg)) {
- print color('yellow'). "Error: $errmsg" .color('clear'). "\n";
- }
- next ATTACH_INTERFACE;
- } else {
- # Do nothing on invalid input
- next COMMAND;
- }
- }
- }
-
- #return $initial_value;
-}
-# Helper function for cli_attach
-sub prompt_filename_from_user {
- my $self = shift @_;
- my $prompt = shift @_;
- my $initial_value = shift @_;
-
- my $term = get_prepped_readline_term();
- # Set a completion function for files
- my $old_compfunc=undef;
- if (my $attr = $term->Attribs) {
- # filename_completion_function works for Gnu Readline
- if (defined($attr->{filename_completion_function})) {
- $old_compfunc = $attr->{completion_entry_function};
- $attr->{completion_entry_function} =
- $attr->{filename_completion_function};
- }
- }
- # TODO - this need more testing with Perl readlines!!!
- if ($self->{term}->ReadLine eq 'Term::ReadLine::Perl') {
- #$readline::rl_completion_function = "rl_filename_list";
- $old_compfunc = $readline::rl_completion_function;
- $readline::rl_completion_function = \&my_nongnu_complete_files;
- } elsif ($self->{term}->ReadLine eq 'Term::ReadLine::Perl5') {
- $old_compfunc=$Term::ReadLine::Perl5::readline::rl_completion_function;
- $Term::ReadLine::Perl5::readline::rl_completion_function =
- \&my_nongnu_complete_files;
- }
- my $filename = $term->readline($prompt, $initial_value);
- $filename =~ s/^\s+//; $filename =~ s/\s+$//; # Trim the input
- # Restore the old completion function
- if (defined($old_compfunc)) {
- if (my $attr = $term->Attribs) {
- $attr->{completion_entry_function} = $old_compfunc;
- }
- if ($self->{term}->ReadLine eq 'Term::ReadLine::Perl') {
- $readline::rl_completion_function = $old_compfunc;
- }
- if ($self->{term}->ReadLine eq 'Term::ReadLine::Perl5') {
- $Term::ReadLine::Perl5::readline::rl_completion_function =
- $old_compfunc;
- }
- }
- return $filename;
-}
-# Helper function for cli_attach
-sub do_attach_file {
- my $entry = shift @_;
- my $path_to_file = shift @_;
- our $state;
-
- if (! -f $path_to_file) {
- return "File not found at: $path_to_file";
- }
- my $size = -s $path_to_file;
- if ($size > $MAX_ATTACH_SIZE) {
- my $sizeK = sprintf("%0.02f", $size / 1024);
- my $maxSizeK = sprintf("%0.02f", $MAX_ATTACH_SIZE / 1024);
- return "File is too large ($sizeK versus $maxSizeK KB maximum).";
- }
- my $fname_short = basename($path_to_file);
- if (defined($entry->{binary}->{$fname_short})) {
- return "An attachement named \"$fname_short\" already exists.\n";
- }
- # If we get this far we're OK to attach it
- open(my $fh,'<',$path_to_file) || return "Couldn't open file $path_to_file";
- read($fh, my $buffer, $size);
- close $fh;
- if (length($buffer) != $size) {
- return "Couldn't read entire key file contents of $path_to_file.\n";
- }
-
- $entry->{binary}->{$fname_short} = $buffer;
- $state->{attach_changed}=1;
- return undef;
-}
-# Cross-platform attempt to find a user's homedir
-sub get_user_homedir {
- if (lc($OSNAME) =~ m/^mswin/ &&
- defined($ENV{HOMEDRIVE}) && defined($ENV{HOMEPATH})) {
- #return $ENV{HOMEDRIVE} . $ENV{HOMEPATH}; # Windows
- return File::Spec->catpath($ENV{HOMEDRIVE}, $ENV{HOMEPATH}, undef);
- }
- my $home = $ENV{HOME} || $ENV{LOGDIR} || (getpwuid($<))[7] || undef; #Unix
- return $home;
-}
-# Expand tildes in filename
-sub expand_tildes {
- my $filename = shift @_;
- # Page 253 of Perl Cookbook By Tom Christiansen, Nathan Torkington
- # "O'Reilly Media, Inc.", Aug 21, 2003
- $filename =~ s{ ^ ~ ( [^/]* ) }
- { $1
- ? (getpwnam($1))[7]
- : ($ENV{HOME} || $ENV{LOGDIR} || (getpwuid($<))[7])
- }ex;
- return $filename;
-}
-
-# Copied from Term::ShellUI and modified for enhanced Windows support
-sub my_nongnu_complete_files {
- my ($str, $line, $start) = @_;
-
- #$self->suppress_completion_append_character();
- if (defined($readline::rl_completer_terminator_character)) {
- $readline::rl_completer_terminator_character='';
- }
- if (defined($Term::ReadLine::Perl5::readline::rl_completer_terminator_character)) {
- $Term::ReadLine::Perl5::readline::rl_completer_terminator_character='';
- }
-
- my ($volume,$directories,$file) = File::Spec->splitpath($str || '.', 0 );
- # This next line is for Windows tab completion on just "C:","D:", etc.
- if (length($volume) && !length($directories)) { $directories='/'; }
- my $dir = File::Spec->catpath($volume,$directories,undef);
-
- # eradicate non-matches immediately (this is important if
- # completing in a directory with 3000+ files)
- $file = '' unless $str;
- my $flen = length($file);
-
- my @files = ();
- if(opendir(DIR, length($dir) ? $dir : '.')) {
- @files = grep { substr($_,0,$flen) eq $file } readdir DIR;
- closedir DIR;
- # eradicate dotfiles unless user's file begins with a dot
- @files = grep { /^[^.]/ } @files unless $file =~ /^\./;
- # reformat filenames to be exactly as user typed
- @files = map { length($dir) ? ($dir eq '/' ? "/$_" : "$dir/$_") : $_ } @files;
- } else {
- print("Couldn't read dir: $!\n");
- }
-
- # Tack trailing slashs on dirs
- foreach my $file_dir (@files) {
- if (-d $file_dir && $file_dir !~m/\/$/) { $file_dir .= '/'; }
- }
-
- return @files;
-}
-
-sub cli_quit($$) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- if ($state->{kdb_has_changed}) {
- print "WARNING: The database has changed and was not saved.\n" .
- "Really quit? [y/N] ";
- my $key=get_single_key();
- if (lc($key) ne 'y') {
- print "\n";
- return -1; # It is not OK to quit
- }
- }
-
- if (-f $state->{placed_lock_file}) { unlink($state->{placed_lock_file}); }
- delete($state->{placed_lock_file});
- $self->exit_requested(1);
- return 0; # It's OK to quit
-}
-
-sub cli_version($$) {
- my $self = shift @_;
- my $params = shift @_;
- our $state;
-
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- # Users can provide a -f option to show the password. We use GetOptions
- # to parse this command line, and $target holds that target.
- my $target='';
- my %opts=();
- {
- local @ARGV = @{$params->{args}};
- my $result = &GetOptions(\%opts, 'v');
- }
-
- if ($opts{'v'}) {
- print "VERSIONS\n";
- print " * kpcli: $VERSION\n";
- my $pv = $PERL_VERSION;
- if (! length($pv)) {
- $pv = $]; # For perl versions prior to 5.6.0
- }
- print " * Perl: $pv\n";
- my @modules = qw(File::KeePass Term::ShellUI Term::ReadKey Term::ReadLine);
- my @missing_modules = ();
- foreach my $module (sort keys %OPTIONAL_PM) {
- if ($OPTIONAL_PM{$module}->{loaded}) {
- push @modules, $module;
- } else {
- push @missing_modules, $module;
- }
- }
- foreach my $module (@modules) {
- no strict 'refs';
- my $vstr=$module . "::VERSION";
- print " * $module: " . ${$vstr} . "\n";
- }
- foreach my $module (@missing_modules) {
- print " * $module: not installed (optional)\n";
- }
- print "\n";
- print "ReadLine being used: " . $term->{term}->ReadLine . "\n";
- # Operating System
- my $OS=$OSNAME;
- if (lc($OSNAME) eq 'linux') {
- my $lsbr = load_lsb_release();
- if (defined($lsbr) && defined($lsbr->{'DISTRIB_DESCRIPTION'})) {
- $OS .= " (" . $lsbr->{'DISTRIB_DESCRIPTION'} . ")";
- }
- } elsif (lc($OSNAME) eq 'mswin') {
- if (! $OPTIONAL_PM{'Win32'}->{loaded}) {
- runtime_load_module(\%OPTIONAL_PM,'Win32',undef);
- }
- if ($OPTIONAL_PM{'Win32'}->{loaded}) {
- $OS .= " (" . Win32::GetOSDisplayName() . ")";
- }
- }
- print "Operating system: $OS\n";
- } else {
- print "$VERSION\n";
- }
-}
-
-sub load_lsb_release {
- my $fh = new FileHandle;
- if (-f '/etc/lsb-release' && open($fh,'<', '/etc/lsb-release')) {
- my @lines = <$fh>;
- close $fh;
- my %d = ();
- foreach my $l (@lines) {
- chomp $l;
- my ($k,$v) = split(/=/, $l, 2);
- $d{$k} = $v;
- }
- return \%d;
- }
- return undef;
-}
-
-# Function to nag the user about saving each time the DB is modified
-sub RequestSaveOnDBChange {
- our $state;
-
- # If the db hasn't changed don't bother the user
- if (! $state->{kdb_has_changed}) {
- return -1;
- }
-
- # If this is a newly created file we don't bother the user with
- # asking to save after every change.
- if (! length($state->{kdb_file})) {
- return -1;
- }
-
- print "Database was modified. Do you want to save it now? [y/N]: ";
- my $key=get_single_key();
- print "\n";
- if (lc($key) ne 'y') {
- return;
- }
-
- # Calling cli_save() should be silent and safe at this point.
- return cli_save(undef);
-}
-
-sub GetMasterPasswd {
- my $prompt = "Please provide the master password: ";
- return GetPassword($prompt,'*');
-}
-
-sub GetMasterPasswdDoubleEntryVerify {
- my $master_pass=GetMasterPasswd();
- if (recent_sigint()) { return undef; } # Bail on SIGINT
-
- if (length($master_pass) == 0) { return ''; }
-
- my $prompt = "Retype to verify: ";
- my $checkval = GetPassword($prompt,'*');
- chomp $checkval;
- if (recent_sigint()) { return undef; } # Bail on SIGINT
- if ($master_pass ne $checkval) {
- print "Passwords did not match...\n";
- return undef;
- }
-
- return $master_pass;
-}
-
-
-sub GetPassword {
- my $prompt = shift @_;
- my $echo_char = shift @_ || '';
-
- if (length($echo_char) > 1) {
- warn "GetPassword() cannot accept an \$echo_char of more than one char.\n";
- $echo_char='';
- }
-
- $|=1; # Needed to flush STDOUT on Windows cmd prior to calling ReadMode
- ReadMode('noecho');
- ReadMode('raw');
- my $master_pass = '';
- print $prompt;
- CHAR: while (1) {
- my $c;
- do {
- Time::HiRes::sleep(0.05);
- } until defined($c = ReadKey(-1));
- last if $c =~ m/[\n\r]/;
- if (ord($c) == 3) { # ^C
- print "\n";
- ReadMode('normal');
- kill SIGINT, $$; # Due to raw mode, I must send the SIGINT to myself.
- return '';
- } elsif (ord($c) == 127 || ord($c) == 8) { # backspace (Linux/Windows)
- if (length($master_pass)) {
- print chr(8)." ".chr(8);
- chop($master_pass);
- }
- next CHAR;
- } elsif (ord($c) == 21) { # ^U
- my $passlen=length($master_pass);
- print chr(8)x$passlen." "x$passlen.chr(8)x$passlen;
- $master_pass = '';
- next CHAR;
- }
- if (length($echo_char)) { print $echo_char; }
- #print "*".ord($c);
- $master_pass .= $c;
- }
- ReadMode('normal');
- chomp $master_pass;
- my $min_display_length = 25;
- if (length($master_pass) < $min_display_length) {
- print "$echo_char"x($min_display_length - length($master_pass));
- }
- print "\n"; $|=1;
- if (recent_sigint()) { return undef; } # Bail on SIGINT
- return $master_pass;
-}
-
-sub MyGetOpts {
- my %opts=();
- my $result = &GetOptions(\%opts, "kdb=s", "key=s", "histfile=s",
- "help", "h", "readonly", "no-recycle");
-
- # If the user asked for help or GetOptions complained, give help and exit
- if ($opts{help} || $opts{h} || (! int($result))) {
- print GetUsageMessage();
- exit;
- }
-
- # Allow the user to override the history file
- if (defined($opts{histfile}) && length($opts{histfile})) {
- our $HISTORY_FILE = $opts{histfile};
- } else {
- if (lc($OSNAME) =~ m/^mswin/) {
- our $HISTORY_FILE = $ENV{USERPROFILE} . "/.$APP_NAME-history";
- } else {
- our $HISTORY_FILE = "~/.$APP_NAME-history";
- }
- }
-
- my @errs=();
- if ((length($opts{kdb}) && (! -e $opts{kdb}))) {
- push @errs, "for option --kdb=<file.kbd>, the file must exist.";
- }
-
- if ((length($opts{key}) && (! -e $opts{key}))) {
- push @errs, "for option --key=<file.key>, the file must exist.";
- }
-
- if (scalar(@errs)) {
- warn "There were errors:\n" .
- " " . join("\n ", @errs) . "\n\n";
- die &GetUsageMessage();
- }
-
- return \%opts;
-}
-
-sub GetUsageMessage {
- my @params = (
- [ kdb => 'Optional KeePass database file to open (must exist).' ],
- [ key => 'Optional KeePass key file (must exist).' ],
- [ histfile => 'Specify your history file (or perhaps /dev/null).' ],
- [ readonly => 'Run in read-only mode; no changes will be allowed.' ],
- [ 'no-recycle' =>
- 'Don\'t store entry changes in /Backup or "/Recycle Bin".' ],
- [ help => 'This message.' ],
- );
- my $t="Usage: $APP_NAME [--kdb=<file.kdb>] [--key=<file.key>]\n" .
- "\n";
- foreach my $param (@params) {
- $t .= sprintf(" %-13s %s\n", '--'.$param->[0], $param->[1]);
- }
- $t .= "\n" .
- "Run kpcli with no options and type 'help' at its command prompt to learn\n" .
- "about kpcli's commands.\n" .
- "";
- return $t;
-}
-
-# Because Term::ShellUI has a fixed width (%20s) for the command length
-# and we don't need nearly that much, we had to implement our own help
-# function instead of using the built-in help_call() method.
-sub my_help_call($) {
- my $term = shift @_;
- # @_ now holds: [Term::ShellUI->{commands}, <optional: specific command>]
- # If the user is asking for detailed help on a specific command, do that
- if (scalar(@_) > 1) {
- $term->help_call(undef, @_);
- return;
- }
- # If no specific command was requested, show the command summaries
- my $help = $term->get_all_cmd_summaries($term->commands());
- $help =~ s/^ {12}//gm; # Trim some leading spaces off of each line of output
- print $help;
- print "\n" .
- "Type \"help <command>\" for more detailed help on a command.\n";
- return 0;
-}
-
-########################################################################
-# Command Completion Routines ##########################################
-########################################################################
-
-sub complete_groups {
- my $self = shift;
- my $cmpl = shift;
- our $state;
-
- # Place the string (token) that the user is trying to complete into $path.
- my $path = $cmpl->{tokens}->[$cmpl->{tokno}];
- # If the cursor isn't at the end of the sting, chop $path to that length.
- if (length($path) > $cmpl->{tokoff}) {
- $path = substr($path, 0, $cmpl->{tokoff});
- }
-
- my $srch_path=normalize_path_string($path);
- my @possibles = ();
- # If the path ends in a "/" (a directory) then we are looking for subirs,
- # else we are just looking to tab-complete a partial dir-name at this level.
- # Used only for /<tab> (the root dir is a special case)
- if ($srch_path =~ m/^$|^[.]$/) {
- @possibles = grep(/^[^\0]+$/,
- sort keys %{$state->{all_grp_paths_fwd}});
- # Used only for /..any/thing../<tab> (non-root directories)
- } elsif (defined($state->{all_grp_paths_fwd}->{$srch_path})) {
- # If the user is sitting on a dir without the trailing /, return that
- # now as the only option (/dir1/dir2<tab>). We do this because the
- # code later does not handle this case well at all. We do, however,
- # have to not do this for things like "ls <tab>" which is why we test
- # for length($path).
- if (length($path) && $path !~ m/\/$/) {
- __my_suppress_completion_append_character($self);
- return [ $cmpl->{str} . "/" ];
- }
- @possibles = grep(/^\Q$srch_path\E\0[^\0]+$/,
- sort keys %{$state->{all_grp_paths_fwd}});
- # Used for /../any/thing../foo<tab>
- } else {
- @possibles = grep(/^\Q$srch_path\E[^\0]*$/,
- sort keys %{$state->{all_grp_paths_fwd}});
- }
-
- # Loop over the possibilites doing required magic...
- my @results=();
- foreach my $opt (@possibles) {
- $opt=humanize_path($opt);
- if ($path =~ m/^(\/+)/) { # Absolute path (easy case!)
- $opt=$1.$opt;
- } else { # Path relative to pwd
- my $user_new_path=get_pwd() . "/" . $path;
- my $new_dir=normalize_path_string($user_new_path);
- # If the user's input does not resolve to a fully qualified
- # path then we need to pop off the last bit to get to that.
- if (! defined($state->{all_grp_paths_fwd}->{$new_dir})) {
- my @path=split(/\0/, $new_dir); pop @path;
- $new_dir = join("\0", @path);
- }
- $new_dir = humanize_path($new_dir);
- # Dirname requires "../." (Trailing dot) to give ".." in those cases!
- my $dirname_path=$path;
- if ($path =~ m/\/$/) { $dirname_path .= "."; }
- my $path_to_put_back=dirname($dirname_path) . "/";
- if ($new_dir eq '') { # All the way at the root level
- $opt = $path_to_put_back . $opt;
- } else { # Some non-root level (deeper)
- $opt=~s/^$new_dir\//$path_to_put_back/;
- }
- # Lop the leading "./" off the head if it was not user supplied
- if ($path !~ m/^[.]\// && $opt =~ m/^[.]\//) { $opt =~ s/^[.]\///; }
- # If the user did supply a leading "./" and we missed it, add it
- if ($path =~ m/^[.]\// && $opt !~ m/^[.]\//) { $opt = "./$opt"; }
- }
- push @results, "$opt/";
- }
-
- # Foreach possibility, we have to strip off the parts that are already
- # completed and then prepend the part that is looking to be completed,
- # from $cmpl->{str}.
- my @completions = ();
- foreach my $possibility (@results) {
- $possibility = normalize_path_string($possibility);
- $possibility =~ s/^\Q$srch_path\E\0?//;
- if (length($possibility)) {
- $possibility = $cmpl->{str} . humanize_path($possibility);
- if ($possibility !~ m/\/$/) { $possibility .= '/'; }
- push @completions, $possibility;
- }
- }
-
- # If we are about to return only one completion result, we need to first
- # test to see if any other subdirs are below it and, if so, suppress the
- # completion append character so that the user can keep tab completing
- # into lower level directories.
- if (scalar(@completions) == 1) {
- my @all_subdirs = grep(/^\Q$srch_path\E[^\0]*(\0[^\0]+)?/,
- sort keys %{$state->{all_grp_paths_fwd}});
- if (scalar(@all_subdirs) > 0) {
- __my_suppress_completion_append_character($self);
- }
- }
-
- return \@completions;
-}
-
-sub complete_entries {
- my $self = shift;
- my $cmpl = shift;
- our $state;
-
- # Place the string (token) that the user is trying to complete into $path.
- my $path = $cmpl->{tokens}->[$cmpl->{tokno}];
- # If the cursor isn't at the end of the sting, chop $path to that length.
- if (length($path) > $cmpl->{tokoff}) {
- $path = substr($path, 0, $cmpl->{tokoff});
- }
-
- my $srch_path=normalize_path_string($path);
- my @entries = grep(/^\Q$srch_path\E\0?[^\0]*$/,
- sort keys %{$state->{all_ent_paths_fwd}});
- # User can tab exactly at a directory level and with or without the
- # trailing slash, and so we need to normalize that, always ensuring that
- # the slash is inserted. We do that by setting the dir_level_sep here
- # and always removing any trailing slash sent in below (\0 on srch_path).
- # We do, however, have to not do this for things like "show <tab>" which
- # is why we test for length($path).
- my $dir_level_sep = '';
- if (defined($state->{all_grp_paths_fwd}->{$srch_path}) &&
- length($path) && $path !~ m/\/$/) {
- $dir_level_sep = '/';
- }
- # Foreach possibility, we have to strip off the parts that are already
- # completed and then prepend the part that is looking to be completed
- # by Term::ShellUI, from $cmpl->{str}.
- my @completions = ();
- foreach my $possibility (@entries) {
- $possibility =~ s/^\Q$srch_path\E\0?//;
- if (length($possibility)) {
- $possibility = $cmpl->{str} .$dir_level_sep. humanize_path($possibility);
- push @completions, $possibility;
- }
- }
-
- return \@completions;
-}
-
-sub complete_groups_and_entries {
- my $self = shift;
- my $cmpl = shift;
-
- my $groups = complete_groups($self,$cmpl);
- my $entries = complete_entries($self,$cmpl);
-
- # Merge and sort the groups and entries
- my @completions = sort (@{$groups}, @{$entries});
- return \@completions;
-}
-
-# In Term::ReadLine::Gnu, suppress_completion_append_character() works,
-# but in Term::ReadLine::Perl and Term::ReadLine::Perl5 it does not, and
-# so we get to the outcome via $readline::rl_completer_terminator_character.
-sub __my_suppress_completion_append_character($self) {
- my $self = shift;
- if ($self->{term}->ReadLine eq 'Term::ReadLine::Gnu') {
- $self->suppress_completion_append_character();
- } else {
- # For Term::ReadLine::Perl
- # From Term/ReadLine/readline.pm
- # - package readline;
- if (defined($readline::rl_completer_terminator_character)) {
- $readline::rl_completer_terminator_character='';
- }
- # For Term::ReadLine::Perl5
- # From Term/ReadLine/Perl5/readline.pm
- # - package Term::ReadLine::Perl5::readline;"
- if (defined(
- $Term::ReadLine::Perl5::readline::rl_completer_terminator_character)) {
- $Term::ReadLine::Perl5::readline::rl_completer_terminator_character='';
- }
- }
-}
-
-########################################################################
-# Rijndael encrypt/decrypt routines - borrowed from File::KeePass ######
-########################################################################
-sub decrypt_rijndael_cbc {
- my ($buffer, $key, $enc_iv) = @_;
- my $cipher = Crypt::Rijndael->new($key, Crypt::Rijndael::MODE_CBC());
- $cipher->set_iv($enc_iv);
- $buffer = $cipher->decrypt($buffer);
- my $extra = ord(substr $buffer, -1, 1);
- substr($buffer, length($buffer) - $extra, $extra, '');
- return $buffer;
-}
-sub encrypt_rijndael_cbc {
- my ($buffer, $key, $enc_iv) = @_;
- my $cipher = Crypt::Rijndael->new($key, Crypt::Rijndael::MODE_CBC());
- $cipher->set_iv($enc_iv);
- my $extra = (16 - length($buffer) % 16) || 16; # pad so we can trim
- $buffer .= chr($extra) for 1 .. $extra;
- return $cipher->encrypt($buffer);
-}
-sub composite_master_pass($$) {
- my ($pass, $key_file) = @_;
-
- # composite password in case of key file
- if (defined $key_file and length($key_file) and -f $key_file) {
- # KeePass v2.03 and higher has native key file support, and so we
- # use that if we have it.
- if (version->parse($File::KeePass::VERSION) >= version->parse('2.03')) {
- return [$pass, $key_file];
- }
- # TODO - at some point, when File::KeePass v2.03 is very mainstream,
- # the code to the end of this if block should be removed. It allowed
- # support for key files for *.kdb files before File::KeePass supported
- # that natively. File::KeePass now also supports that for *.kdbx.
- open(my $fh,'<',$key_file) || die "Couldn't open key file $key_file: $!\n";
- my $size = -s $key_file;
- read($fh, my $buffer, $size);
- close $fh;
- if (length($buffer) != $size) {
- die "Couldn't read entire key file contents of $key_file.\n";
- }
-
- $pass = substr(sha256($pass),0,32);
- if ($size == 32) {
- $pass .= $buffer;
- } elsif ($size == 64) {
- for (my $i = 0; $i < 64; $i += 2) {
- $pass .= chr(hex(substr($buffer,$i,2)));
- }
- } else {
- $pass .= substr(sha256($buffer),0,32);
- }
- # TODO - this marks the "end of the block" noted above.
- }
-
- return $pass;
-}
-sub put_master_passwd($) {
- my $master_pass = shift @_;
- our $state;
- $state->{'master_pass_key'}='';
- $state->{'master_pass_key'} .= chr(int(255 * rand())) for 1..16;
- $state->{'master_pass_enc_iv'}='';
- $state->{'master_pass_enc_iv'} .= chr(int(255 * rand())) for 1..16;
- $master_pass='CLEAR:' . $master_pass;
- $state->{'master_pass'}=encrypt_rijndael_cbc($master_pass,
- $state->{'master_pass_key'}, $state->{'master_pass_enc_iv'});
- return 0;
-}
-sub get_master_passwd() {
- our $state;
- my $master_pass=decrypt_rijndael_cbc($state->{master_pass},
- $state->{'master_pass_key'}, $state->{'master_pass_enc_iv'});
- if ($master_pass=~s/^CLEAR://) {
- return $master_pass;
- } else {
- die "Failed to properly decrypt my copy of the master password.\n";
- }
-}
-
-# This routine checks to see if the file has changed on disk and warns if so
-sub warn_if_file_changed {
- our $state;
-
- my $file = $state->{kdb_file};
- if (! length($file)) { return 0; } # If no file was opened, don't warn
- my $file_md5 = Digest::file::digest_file_hex($file, "MD5");
- if ($state->{kdb_file_md5} ne $file_md5) {
- print color('bold yellow') . "WARNING:" . color('clear') .
- color('red') .
- " The file has changed on disk since kpcli opened it!\n" .
- " It may be opened elsewhere. Continue anyway? [y/N] " .
- color('clear');
- my $key=get_single_key();
- print "\n";
- if (lc($key) ne 'y') {
- return -1;
- }
- }
-
- return 0;
-}
-
-sub generateKeyfileContents($) {
- my $length = shift;
- my $password = '';
- my @normal_chars=('a'..'z','A'..'Z',0..9);
- # all printable non-alnum chars except space (0x20), backspace (0x5c),
- # and backtick (0x60)
- my @special_chars=map(chr, 0x21 .. 0x2f, 0x3a .. 0x40,
- 0x5b, 0x5d .. 0x5f, 0x7b .. 0x7e);
- my $charset = join('', (@normal_chars, at special_chars));
- while (length($password) < $length) {
- $password .= substr($charset, (int(rand(length($charset)))), 1);
- }
- return $password;
-}
-
-sub generatePassword {
- my $be_silent = shift @_ || 0;
- my $password = generatePasswordFromDict($be_silent);
- if (! length($password)) {
- if (! $be_silent) {
- print color('yellow') .
- "Generated random characters instead of a words-based password.\n" .
- color('clear');
- }
- $password = generatePasswordGobbledygook(20);
- }
- return $password;
-}
-
-sub generatePasswordGobbledygook {
- my $length = shift;
- my @normal_chars=('a'..'z','A'..'Z',0..9);
- my @special_chars=qw(_);
- my $charset = join('', (@normal_chars, at special_chars));
- # Generate the password
- my $password = '';
- while (length($password) < $length) {
- $password .= substr($charset, (int(rand(length($charset)))), 1);
- }
- # Make sure that at least one special character appears
- my $sccc=join('', @special_chars);
- if ($password !~ m/[\Q$sccc\E]/) {
- my $sc=$special_chars[int(rand(length($sccc)))];
- substr($password,int(rand(length($password))), 1, $sc);
- }
- return $password
-}
-
-sub genPassInteractiveHelper($$) {
- my $mode = shift @_;
- my $len = shift @_;
- if ($mode eq 'g') {
- return generatePasswordGobbledygook($len);
- } elsif ($mode eq 'w') {
- return generatePasswordFromDict(0);
- } else {
- return undef;
- }
-}
-
-sub generatePasswordInteractive($) {
- my $default_passwd_len = shift @_;
-
- print "\n";
- print "INTERACTIVE PASSWORD GENERATION:\n";
- print "<n>o/<ret> Do not accept current password, generate another one.\n";
- print "<y>es Accept current password.\n\n";
- print "<t>oggle Toggle between random characters and word-based mode.\n";
- print "<c>ancel Abort interactive password generation mode.\n";
- print "And in random characters mode:\n";
- print " +/- Increase/decrease password length. " .
- "May be prefixed with a count.\n";
- print " [n]= Set password length to [n] chars. If not given, " .
- "resets to $DEFAULT_PASSWD_LEN chars.\n";
-
- my $pw_is_ok = 0;
- my $len = $default_passwd_len;
-
- my $mode = 'g';
- my $pw = genPassInteractiveHelper($mode,$len);
- do {
- my $prompt_format = "%s +/-/= n/y/t/c ";
- if ($mode eq 'w') { $prompt_format = "%s n/y/t/c "; }
- my $prompt = sprintf($prompt_format, $pw);
- print $prompt;
- my $input = '';
- my $input_complete = 0;
- do {
- if (recent_sigint()) { return undef; }
- my $k = get_single_key();
- if (recent_sigint()) { return undef; }
- my $kc = unpack("C", $k);
- if ($kc == 0x03) { # ^C
- return undef;
- } elsif ($kc == 127 || $kc == 8) { # backspace (Linux/Windows)
- if (length($input) > 0) {
- $input = substr($input,0,length($input) - 1);
- print chr(8)." ".chr(8); # Erase a character
- #print $k; # Print the backspace key to erase a character
- }
- } elsif ($kc == 0x0a) {
- $input = '';
- $input_complete = 1;
- } else {
- # Validate character input then process it
- if ($input eq '' && $k =~ m/^[tcny]$/
- || ($mode eq 'g' && $k =~ m/^[\d\+\-=]$/ && $input =~ m/^\d*$/)
- ) {
- $input .= $k;
- if ($mode eq 'g' && $input =~ /^(\d+)?[+=-]$/) {$input_complete=1;}
- if ($input =~ /^[tcny]$/ ) { $input_complete = 1; }
- if ($input =~ /^\d/) { print $k; }
- }
- }
- } while (!$input_complete);
- print "\n";
-
- if (lc($input) eq 't') {
- if ($mode eq 'g') { $mode = 'w' } else { $mode = 'g'; }
- $input = 'n';
- }
-
- if ($input eq 'y') {
- length($pw) and $pw_is_ok = 1;
- } elsif ($mode eq 'g' && $input =~ /^(\d+)?[+=-]$/) {
- my $new_len = $DEFAULT_PASSWD_LEN;
- if ($input =~ /^(\d+)?=$/) {
- $new_len = $1 || $DEFAULT_PASSWD_LEN;
- } elsif ($input =~ /^(\d+)?([+-])$/) {
- my $v = $1 ? $1 : 1;
- $v *= $2 eq '-' ? -1 : 1;
- $new_len = $len + $v;
- }
- if ($new_len < $DEFAULT_PASSWD_MIN) {
- $len = $DEFAULT_PASSWD_MIN;
- } elsif ($new_len > $DEFAULT_PASSWD_MAX) {
- $len = $DEFAULT_PASSWD_MAX;
- } else {
- $len = $new_len;
- printf "[%s%s%s]\n",
- '-' x ($len - $DEFAULT_PASSWD_MIN), '|',
- '-' x ($DEFAULT_PASSWD_MAX - $len);
- }
- if (recent_sigint()) { return undef; }
- $pw = genPassInteractiveHelper($mode,$len);
- } elsif ($input eq 'c') {
- return undef; # Return undef on cancel
- } elsif ($input eq 'n' || !$input) {
- if (recent_sigint()) { return undef; }
- $pw = genPassInteractiveHelper($mode,$len);
- }
- } while (!$pw_is_ok);
- return($pw);
-}
-
-# Inspired by http://xkcd.com/936/
-sub generatePasswordFromDict($) {
- my $be_silent = shift @_ || 0;
- my @words=();
- my $fh = new FileHandle;
- my @dict_files = qw(/usr/share/dict/words /usr/dict/words);
- DICTS: foreach my $dictfile (@dict_files) {
- if (-f $dictfile && -r $dictfile && open($fh,'<', $dictfile)) {
- @words = <$fh>;
- close($fh);
- last DICTS;
- }
- }
- if (scalar(@words) < 10000) {
- if (! $be_silent) {
- print color('yellow') .
- "No adequate dictionary found to generate a words-based password.\n".
- "These locations were checked:\n - ".join("\n - ", @dict_files)."\n" .
- color('clear');
- }
- return undef;
- }
- my $length_tries = 12;
- my $password='';
- do {
- my @passwords = ();
- my $word_tries=20;
- while (scalar(@passwords) < 4 && $word_tries-- > 0) {
- my $word = $words[int(rand(scalar(@words)))];
- chomp $word;
- $word =~ s/[^a-zA-Z0-9]//g;
- if (length($word) > 7) { next; } # Don't care for big words
- push @passwords, $word;
- }
- if (scalar(@passwords) == 4) {
- $password=join('.', @passwords);
- }
- #warn "LHHD: $password\n";
- if (length($password) < 27) { $password=''; } # Need a decent overall size
- } until (length($password) || $length_tries-- < 0);
-
- return $password;
-}
-
-
-#########################################################################
-# Setup signal handling #################################################
-#########################################################################
-sub setup_signal_handling {
- our $state;
-
- # We only worry with signal handling for Term::ReadLine::Gnu
- if ($state->{'term'}->{term}->ReadLine ne 'Term::ReadLine::Gnu') {
- return 0;
- }
-
- # We don't want Term::Readline::Gnu catching signals, except for WINCH.
- # I really don't understand why, but I know via experimentation.
- my $term = $state->{'term'}->{term};
- $term->Attribs->{catch_signals} = 0;
- $term->Attribs->{catch_sigwinch} = 1; # Window resizes
- $term->clear_signals();
-
- # Install a signal handler to catch SIGINT (^C). Unsafe signal handling
- # (through POSIX::SigAction) is required to deal with Term::ReadLine::Gnu.
- # We don't even try for other readlines due to their limited functionality.
- my $handler_SIGINT = sub {
- our $state;
- # We could be using one of a couple of ReadLine terminals; the one
- # from Term::ShellUI ($state->{'term'}->{term}) or one from one of
- # our cli_NNN commands ($state->{active_readline}). We will assume
- # the Term::ShellUI one here, and override that below if needed.
- my $term = $state->{'term'}->{term};
-
- # Record in our state when a SIGINT was last received
- $state->{signals}->{INT} = [gettimeofday];
-
- # We need to pull the Carp longmess to see if we're sitting in a
- # a cli_XXXX function instead of at a readline prompt.
- my $mess = longmess();
- #print Dumper( $mess );
- # At some point, Term::ShellUI started wrapping my cli_XXX() routines
- # in eval{}, which hid the cli_\w+() from the longmess, and so I had
- # to add a second condition here.
- if ($mess =~ m/(main::(cli_\w+)\(|Term::ShellUI::call_cmd\()/) {
- #warn "It appears that SIGINT was called from $1\n";
- #warn "LHHD: $mess\n";
- # If the cli_NNN has an active_readline we need to work with it
- if (defined($state->{active_readline})) {
- #warn "LHHD: in INT with active_readline\n";
- my $term = $state->{active_readline};
- $term->free_line_state();
- $term->cleanup_after_signal();
- $term->reset_after_signal();
- $term->Attribs->{line_buffer}=''; # Clear the buffer
- $term->Attribs->{done}=1; # Ask readline to return immediately
- }
- } else { # If not in a cli_XXX(), assume a Term::ShellUI prompt
- my $yellow=color('yellow');
- my $clear=color('clear');
- #$term->echo_signal_char(SIGINT); # Puts ^C on the next line. :(
- # Trial and error on these readline_state values... :(
- #warn "LHHD: " . sprintf($term->Attribs->{readline_state}) . "\n";
- if ($term->Attribs->{readline_state} == 262374) {
- print "^C$yellow - use Ctrl-g to stop history search.$clear\n";
- } else {
- print "^C$yellow - use the \"quit\" command to exit.$clear\n";
- }
- $term->free_line_state();
- $term->cleanup_after_signal();
- $term->reset_after_signal();
- $term->Attribs->{line_buffer}=""; # Clear the input buffer
- $term->forced_update_display(); # Force update the display
- }
- return 0;
- };
- sigaction(SIGINT, new POSIX::SigAction($handler_SIGINT));
- #$SIG{INT} = $handler_SIGINT; # Works only if $ENV{PERL_SIGNAL}='unsafe'
- #https://groups.google.com/forum/#!topic/perl.perl5.porters/fNJdyyZh7Wc
-
- # Handle signal CONT - continue signal (resuming after Ctrl-Z).
- my $handler_SIGCONT = sub {
- our $state;
- my $term = $state->{'term'}->{term};
- my $mess = longmess();
- # At some point, Term::ShellUI started wrapping my cli_XXX()
- # routines in eval{}, which hid the cli_\w+() from the longmess,
- # and so I had to add a second condition here.
- if ($mess =~ m/(main::(cli_\w+)\(|Term::ShellUI::call_cmd\()/ &&
- defined($state->{active_readline})) {
- $term = $state->{active_readline};
- }
- $term->cleanup_after_signal();
- $term->reset_after_signal();
- $term->forced_update_display(); # Force update the display
- };
- sigaction(SIGCONT, new POSIX::SigAction($handler_SIGCONT));
- #$SIG{CONT} = $handler_SIGCONT; # Works only if $ENV{PERL_SIGNAL}='unsafe'
-}
-
-# Code consolidation function to runtime-load optional perl modules
-sub runtime_load_module($$$) {
- my $rOPTIONAL_PM = shift @_;
- my $module = shift @_;
- my $rImportList = shift @_ || undef;
- my $iltxt = '';
- if (defined($rImportList) && ref($rImportList) ne 'ARRAY') {
- die "The rImportList param to runtime_load_module() must be an ARRAY ref\n";
- }
- if (defined($rImportList) && ref($rImportList) eq 'ARRAY' &&
- scalar(@$rImportList) > 0) {
- $iltxt = "('" . join("','", @{$rImportList}) . "')";
- }
- if (eval "require $module;$module->import($iltxt);1;" eq 1) {
- $rOPTIONAL_PM->{$module}->{loaded} = 1;
- return 1;
- } else {
- $rOPTIONAL_PM->{$module}->{loaded} = 0;
- return 0;
- }
-}
-
-# This routine runs down the list of our preferred Term::ReadLine::*
-# modules and returns a new object from the first one that we find.
-sub get_readline_term($$) {
- my $rOPTIONAL_PM = shift @_;
- my $app_name = shift @_;
- my @rl_modules = ();
- # The list of readlines that we support
- push @rl_modules, 'Term::ReadLine::Gnu';
- push @rl_modules, 'Term::ReadLine::Perl';
- push @rl_modules, 'Term::ReadLine::Perl5';
- my $rl_term = undef;
- my $hold_TERM=undef;
- MODULE: foreach my $module (@rl_modules) {
- # On MS Windows, Term::ReadLine::Perl and Term::ReadLine::Perl5 are
- # pretty good terminals but they behave badly if the environment
- # variable TERM=dumb, and so we override that here if needed.
- if (lc($OSNAME) =~ m/^mswin/ && $module =~ m/^Term::ReadLine::Perl5?/ &&
- $ENV{'TERM'} eq 'dumb') {
- $hold_TERM=$ENV{'TERM'};
- $ENV{'TERM'} = 'vt102';
- }
- if (runtime_load_module($rOPTIONAL_PM,$module,undef) eq 1) {
- # These SGI{'__WARN__'} shenanigans are to suppress:
- # WARNING: Use of inherited AUTOLOAD for non-method
- # Term::ReadLine::Gnu::ornaments() is deprecated at
- # /usr/lib/perl5/Term/ReadLine/Gnu.pm line 250.
- # Hopefully, newer versions of Term::ReadLine::Gnu will fix this.
- $SIG{'__WARN__'} =
- sub { warn $_[0] unless (caller eq "Term::ReadLine::Gnu"); };
- $rl_term = eval "$module->new('$app_name');";
- delete $SIG{'__WARN__'};
- last MODULE;
- } else {
- #warn "Loading $module failed\n";
- if (defined($hold_TERM)) { $ENV{'TERM'} = $hold_TERM; $hold_TERM=undef; }
- }
- }
-
- #if (! defined($rl_term)) { return undef; }
- if (! defined($rl_term)) {
- die "No usable Term::ReadLine::* modules found.\n" .
- "This list tried: " . join(', ', @rl_modules) . "\n";
- }
-
- # I don't like readline ornaments in kpcli
- if (lc($OSNAME) =~ m/^mswin/ && $rOPTIONAL_PM->{'Capture::Tiny'}->{loaded}) {
- # On MS Windows, the RLTERM->ornaments() call causes a warning about
- # not having a termcap file. It seems hamless and so we suppress that
- # message if we have Capture::Tiny available.
- my ($out, $err, @result) = capture( sub { $rl_term->ornaments(0); } );
- if (length($err) && $err !~ m/^cannot find termcap/i) { warn $err; }
- } else {
- # WARNING: Use of inherited AUTOLOAD for non-method
- # Term::ReadLine::Gnu::ornaments() is deprecated
- # at line <two lines below>.
- # This "no warnings" is to stop that, but the same warning
- # still comes from Term::ReadLine::Gnu at line 250 with perl
- # v5.14.2 and Term::ReadLine::Gnu 1.20-2. That is suppressed
- # in a different way, just above in this same function.
- no warnings 'once';
- $rl_term->ornaments(0);
- }
-
- # I'm not sure that these are only needed on Windows, but I know they
- # are not needed on Linux so I'm trying to keep the scope narrow.
- if (lc($OSNAME) =~ m/^mswin/ &&
- $rl_term->ReadLine =~ m/Term::ReadLine::Perl5?/) {
- # For Term::ReadLine::Perl and Term::ReadLine::Perl we set
- # $readline::rl_scroll_nextline=0 on MS Windows.
- if (defined($readline::rl_scroll_nextline)) {
- $readline::rl_scroll_nextline=0;
- }
- if (defined($Term::ReadLine::Perl5::readline::rl_scroll_nextline)) {
- $Term::ReadLine::Perl5::readline::rl_scroll_nextline=0;
- }
- }
-
- # History
- # The Term::ReadLine::Perl* modules shove values into the history
- # file automtically. That causes two problems for kpcli:
- # 1) Term::ShellUI calls $term->addhistory() itself for each CLI
- # command that it wants to store ih history (duplicates).
- # 2) We use readline() in places like cli_new, cli_edit, etc. and
- # we do not want all those inputs in the history file (cruft).
- if ($rl_term->ReadLine eq 'Term::ReadLine::Perl') {
- no strict 'refs';
- *readline::add_line_to_history = sub { return undef; };
- }
- if ($rl_term->ReadLine eq 'Term::ReadLine::Perl5') {
- no strict 'refs';
- *Term::ReadLine::Perl5::readline::add_line_to_history = sub {return undef;}
- }
-
- if (defined($hold_TERM)) { $ENV{'TERM'} = $hold_TERM; }
-
- return $rl_term;
-}
-
-# Use simple magic recipes to identify relevant file types
-sub magic_file_type($) {
- my $filename = shift @_;
- my $fh=new FileHandle;
- my $header='';
- if (open($fh, '<', $filename)) {
- my $n = read $fh, $header, 4;
- close $fh;
- }
- # KeePass
- # Recipe from https://github.com/glensc/file/blob/master/magic/Magdir/keepass
- # 0 lelong 0x9AA2D903 Keepass password database
- # >4 lelong 0xB54BFB65 1.x KDB
- # >>48 lelong >0 \b, %d groups
- # >>52 lelong >0 \b, %d entries
- # >>8 lelong&0x0f 1 \b, SHA-256
- # >>8 lelong&0x0f 2 \b, AES
- # >>8 lelong&0x0f 4 \b, RC4
- # >>8 lelong&0x0f 8 \b, Twofish
- # >>120 lelong >0 \b, %d key transformation rounds
- # >4 lelong 0xB54BFB67 2.x KDBX
- if ($header =~ m/^\x03\xd9\xa2\x9a/) {
- return 'keepass';
- }
- # Password Safe v3
- # Recipe from https://github.com/glensc/file/blob/master/magic/Magdir/pwsafe
- if ($header =~ m/^PWS3/) {
- return 'pws3';
- }
- return undef;
-}
-
-########################################################################
-# Unix-style, "touch" a file
-########################################################################
-sub touch_file {
- my $filename = shift @_;
- if (! -f $filename) {
- my $fh=new FileHandle;
- open($fh, "> $filename");
- close($fh);
- }
- my $sig_pipe_store=$SIG{'PIPE'};
- $SIG{'PIPE'} = 'IGNORE';
- my $now=time;
- my $retval=utime $now, $now, $filename;
- $SIG{'PIPE'} = $sig_pipe_store;
-return($retval);
-}
-
-########################################################################
-# POD ##################################################################
-########################################################################
-
-=head1 NAME
-
-kpcli - A command line interface to KeePass database files.
-
-=head1 DESCRIPTION
-
-A command line interface (interactive shell) to work with KeePass
-database files (http://http://en.wikipedia.org/wiki/KeePass). This
-program was inspired by my use of "kedpm -c" combined with my need
-to migrate to KeePass. The curious can read about the Ked Password
-Manager at http://kedpm.sourceforge.net/.
-
-=head1 USAGE
-
-Please run the program and type "help" to learn how to use it.
-
-=head1 PREREQUISITES
-
-This program requires these non-core modules:
-
-C<Crypt::Rijndael> - libcrypt-rijndael-perl on Ubuntu 10.04
-
-C<Term::ReadKey> - libterm-readkey-perl on Ubuntu 10.04
-
-C<Sort::Naturally> - libsort-naturally-perl on Ubuntu 10.04
-
-C<File::KeePass> - libfile-keepass-perl on Ubuntu 12.04
-
-C<Term::ShellUI> - libterm-shellui-perl on Ubuntu 12.10
-
-It is recommended that you install C<Term::ReadLine::Gnu> which will
-provide more fluid signal handling on Unix-like systems, making kpcli
-robust to suspend, resume, and interupt - SIGSTP, SIGCONT and SIGINT.
-That module is in the libterm-readline-gnu-perl package on Ubuntu.
-
-You can optionally install C<Clipboard> and C<Tiny::Capture> to use the
-clipboard features; http://search.cpan.org/~king/Clipboard/ and
-libcapture-tiny-perl on Ubuntu 10.04.
-
-You can optionally install C<Data::Password> to use the pwck feature
-(Password Quality Check); libdata-password-perl on Ubuntu 10.04.
-
-On MS Windows, you can optionally install C<Win32::Console::ANSI> to get
-ANSI colors in Windows cmd terminals. Strawberry Perl 5.16.2 was used
-for the kpcli port to Windows and, using cpanminus, one can install all
-of kpcli's dependencies, sans Term::ReadLine::Gnu which is optional for
-kpcli and not supported on MS Windows.
-
-=head1 CAVEATS AND WORDS OF CAUTION
-
-The main author of kpcli primarily interoperability tests with KeePassX
-(http://www.keepassx.org/) and primarily uses KeePass v1 (*.kdb) files.
-Support for KeePass v2 (*.kdbx) files in kpcli is substantial, and many
-people use it daily, but it is not the author's primary use case. It is
-also the author's intent to maintain compatibility with v1 files, and so
-anyone sending patches, for consideration for inclusion in future kpcli
-versions, is asked to validate them with both v1 and v2 files.
-
-=head2 No history tracking for KeePass 2 (*.kdbx) files
-
-Recording entries' history in KeePass 2 files is not implemented. History
-that exists in a file is not destroyed, but results of entry changes made
-in kpcli are not recorded into their history. Prior-to-change copies are
-stored into the "Recycle Bin." Note that File::KeePass does not encrypt
-passwords of history entries in RAM, like it does for current entries.
-This is a small security risk that can, in theory, allow priviledged users
-to steal your passwords from RAM, from entry history.
-
-=head2 File::KeePass bug prior to version 2.03
-
-Prior to version 2.03, File::KeePass had a bug related to some "unknown"
-data that KeePassX stores in group records. For File::KeePass < v2.03,
-kpcli deletes those unknown data when saving. Research in the libkpass
-(http://libkpass.sourceforge.net/) source code revealed that what early
-versions of File::KeePass classifies as "unknown" are the times for
-created/modified/accessed/expires as well as "flags" (id=9), but only for
-groups; File::KeePass handled those fields just fine for entries. I found
-no ill-effect from dropping those fields when saving and so that is what
-kpcli does to work around the File::KeePass bug, if kpcli is using
-File::KeePass < v2.03.
-
-=head1 BUGS
-
-=head2 Using Ctrl-D to Exit
-
-Versions of Term::ShellUI prior to v0.9. do not have the ability to trap
-Ctrl-D exits by the client program. I submitted a patch to remedy that and
-it made it into Term::ShellUI v0.9. Please upgrade if kpcli asks you to.
-
-=head2 Multiple Entries or Groups With the Same Name in the Same Group
-
-This program does not support multiple entries in the same group having
-the exact same name, nor does it support multiple groups at the same
-level having the same name, and it likely never will. KeepassX does
-support those. This program detects and alert when an opened database
-file has those issues, but it does not refuse to save (overwrite) a file
-that is opened like that. Saves are actually safe (no data loss) as long
-as the user has not touched one of the duplicately-named items.
-
-=head1 AUTHOR
-
-Lester Hightower <hightowe at cpan dot org>
-
-=head1 LICENSE
-
-This program may be distributed under the same terms as Perl itself.
-
-=head1 CREDITS
-
-Special thanks to Paul Seamons, author of C<File::KeePass>, and to
-Scott Bronson, author of C<Term::ShellUI>. Without those two modules
-this program would not have been practical for me to author.
-
-=head1 CHANGELOG
-
- 2010-Nov-28 v0.1 - Initial release.
- 2010-Nov-28 v0.2 - Encrypt the master password in RAM.
- 2010-Nov-29 v0.3 - Fixed master password encryption for saveas.
- 2010-Nov-29 v0.4 - Fixed code to work w/out Term::ReadLine::Gnu.
- Documented File::KeePass v0.1 hierarchy bug.
- 2010-Nov-29 v0.5 - Made find command case insensitive.
- Bugfix in new command (path regex problem).
- 2010-Nov-29 v0.6 - Added lock file support; warn if a lock exists.
- 2010-Dec-01 v0.7 - Further documented the group fields that are
- dropped, in the CAVEATS section of the POD.
- Sort group and entry titles naturally.
- 2010-Dec-23 v0.8 - Worked with File::KeePass author to fix a couple
- of bugs and then required >=v0.03 of that module.
- Sorted "/_found" to last in the root group list.
- Fixed a "database changed" state bug in cli_save().
- Made the find command ignore entries in /Backup/.
- Find now offers show when only one entry is found.
- Provided a patch to Term::ShellUI author to add
- eof_exit_hook and added support for it to kpcli.
- 2011-Feb-19 v0.9 - Fixed bugs related to spaces in group names as
- reported in SourceForge bug number 3132258.
- The edit command now prompts to save on changes.
- Put scrub_unknown_values_from_all_groups() calls
- back into place after realizing that v0.03 of
- File::KeePass did not resolve all of the problems.
- 2011-Apr-23 v1.0 - Changed a perl 5.10+ regex to a backward-compatable
- one to resolve SourceForge bug number 3192413.
- Modified the way that the /Backup group is ignored
- by the find command to stop kpcli from croaking on
- multiple entries with the same name in that group.
- - Note: There is a more general bug here that
- needs addressing (see BUGS section).
- An empty title on new entry aborts the new entry.
- Changed kdb files are now detected/warned about.
- Tested against Term::ShellUI v0.9, which has my EOF
- hook patch, and updated kpcli comments about it.
- Term::ShellUI's complete_history() method was
- removed between v0.86 and v0.9 and so I removed
- kpli's call to it (Ctrl-r works for history).
- Added the "icons" command.
- 2011-Sep-07 v1.1 - Empty DBs are now initialized to KeePassX style.
- Fixed a couple of bugs in the find command.
- Fixed a password noecho bug in the saveas command.
- Fixed a kdb_has_changed bug in the saveas command.
- Fixed a cli_open bug where it wasn't cli_close'ing.
- Fixed variable init bugs in put_master_passwd().
- Fixed a false warning in warn_if_file_changed().
- 2011-Sep-30 v1.2 - Added the "export" command.
- Added the "import" command.
- Command "rmdir" asks then deletes non-empty groups.
- Command "new" can auto-generate random passwords.
- 2012-Mar-03 v1.3 - Fixed bug in cl command as reported in SourceForge
- bug number 3496544.
- 2012-Apr-17 v1.4 - Added key file support based on a user contributed
- patch with SourceForge ID# 3518388.
- Added my_help_call() to allow for longer and more
- descriptive command summaries (for help command).
- Stopped allowing empty passwords for export.
- 2012-Oct-13 v1.5 - Fixed "help <foo>" commands, that I broke in v1.4.
- Command "edit" can auto-generate random passwords.
- Added the "cls" and "clear" commands from a patch
- with SourceForge ID# 3573930.
- Tested compatibility with File::KeePass v2.03 and
- made minor changes that are possible with >=2.01.
- With File::KeePass v2.03, kpcli should now support
- KeePass v2 files (*.kdbx).
- 2012-Nov-25 v1.6 - Hide passwords (red on red) in the show command
- unless the -f option is given.
- Added the --readonly command line option.
- Added support for multi-line notes/comments;
- input ends on a line holding a single ".".
- 2013-Apr-25 v1.7 - Patched to use native File::KeePass support for key
- files, if the File::KeePass version is new enough.
- Added the "version" and "ver" commands.
- Updated documentation as Ubuntu 12.10 now packages
- all of kpcli's dependencies.
- Added --histfile command line option.
- Record modified times on edited records, from a
- patch with SourceForge ID# 3611713.
- Added the -a option to the show command.
- 2013-Jun-09 v2.0 - Removed the unused Clone module after a report that
- Clone is no longer in core Perl as of v5.18.0.
- Added the stats and pwck commands.
- Added clipboard commands (xw/xu/xp/xx).
- Fixed some long-standing tab completion bugs.
- Warn if multiple groups or entries are titled the
- same within a group, except for /Backup entries.
- 2013-Jun-10 v2.1 - Fixed several more tab completion bugs, and they
- were serious enough to warrant a quick release.
- 2013-Jun-16 v2.2 - Trap and handle SIGINT (^C presses).
- Trap and handle SIGTSTP (^Z presses).
- Trap and handle SIGCONT (continues after ^Z).
- Stopped printing found dictionary words in pwck.
- 2013-Jul-01 v2.3 - More readline() and signal handling improvements.
- Title conflict checks in cli_new()/edit()/mv().
- Group title conflict checks in rename().
- cli_new() now accepts optional path&|title param.
- cli_ls() can now list multiple paths.
- cli_edit() now shows the "old" values for users
- to edit, if Term::ReadLine::Gnu is available.
- cli_edit() now aborts all changes on ^C.
- cli_saveas() now asks before overwriting a file.
- 2013-Nov-26 v2.4 - Fixed several "perl -cw" warnings reported on
- 2013-07-09 as SourceForge bug #9.
- Bug fix for the cl command, but in sub cli_ls().
- First pass at Strawberry perl/MS Windows support.
- - Enhanced support for Term::ReadLine::Perl
- - Added support for Term::ReadLine::Perl5
- Added display of expire time for show -a.
- Added -a option to the find command.
- Used the new magic_file_type() in a few places.
- Added generatePasswordFromDict() and "w" generation.
- Added the -v option to the version command.
- - Added the versions command.
- 2014-Mar-15 v2.5 - Added length control (gNN) to password generation.
- Added the copy command (and cp alias).
- Added the clone command.
- Added optional modules not installed to version -v.
- Groups can now also be moved with the mv command.
- Modified cli_cls() to also work on MS Windows.
- Suppressed Term::ReadLine::Gnu hint on MS Windows.
- Suppressed missing termcap warning on MS Windows.
- Print a min number of *s to not leak passwd length.
- Removed unneeded use of Term::ReadLine.
- Quieted "inherited AUTOLOAD for non-method" warns
- caused by Term::Readline::Gnu on perl 5.14.x.
- 2014-Jun-06 v2.6 - Added interactive password generation ("i" method).
- - Thanks to Florian Tham for the idea and patch.
- Show entry's tags if present (KeePass >= v2.11).
- - Thanks to Florian Tham for the patch.
- Add/edit support for tags if a v2 file is opened.
- Added tags to the searched fields for "find -a".
- Show string fields (key/val pairs) in v2 files.
- Add/edit for string fields if a v2 file is opened.
- Show information about entries' file attachments.
- 2014-03-20 SourceForge feature request #6.
- New "attach" command to manage file attachments.
- Added "Recycle Bin" functionality and --no-recycle.
- For --readonly, don't create a lock file and don't
- warn if one exists. 2014-03-27 SourceForge bug #11.
- Added key file generation to saveas and export.
- 2014-04-19 SourceForge bug #13.
- Added -expired option to the find command.
- Added "dir" as an alias for "ls"
- Added some additional info to the stats command.
- Added more detailed OS info for Linux/Win in vers.
- Now hides Meta-Info/SYSTEM entries.
- Fixed bug with SIGTSTP handling (^Z presses).
- Fixed missing refresh_state_all_paths() in cli_rm.
- 2014-Jun-11 v2.7 - Bug fix release. Broke the open command in 2.6.
-
-=head1 TODO ITEMS
-
- Consider adding a purge command for "Backup"/"Recycle Bin" folders.
-
- Consider adding a tags command for use with v2 files.
- - To navigate by entry tags
-
- Consider supporting KeePass 2.x style entry history.
- - There are potential security implications in File::KeePass.
- - Related, consider adding a purge command for that history.
-
- Consider adding KeePass 2.x style multi-user synchronization.
-
- Consider http://search.cpan.org/~sherwin/Data-Password-passwdqc/
- for password quality checking.
-
- Consider adding searches for created, modified, and accessed times
- older than a user supplied time.
-
- Consider adding import support for Password Safe v3 files using
- http://search.cpan.org/~tlinden/Crypt-PWSafe3/. May have already
- done this if the unpackaged dependencies list was not so long.
-
-=head1 OPERATING SYSTEMS AND SCRIPT CATEGORIZATION
-
-=pod OSNAMES
-
-Unix-like
- - Originally written and tested on Ubuntu Linux 10.04.1 LTS.
- - As of version 2.5, development is done on Linux Mint 16.
- - Known to work on many other Linux and *BSD distributions, and
- kpcli is packaged with many distributions now-a-days.
-
-Microsoft Windows
- - As of v2.4, Microsoft Windows is also supported.
- - Tested and compiled on Strawberry Perl 5.16.2 on Windows 7.
-
-=pod SCRIPT CATEGORIES
-
-C<UNIX/System_administration>, C<Win32/Utilities>
-
diff --git a/kpcli-2.8.pl b/kpcli-2.8.pl
new file mode 100644
index 0000000..c759879
--- /dev/null
+++ b/kpcli-2.8.pl
@@ -0,0 +1,5018 @@
+#!/usr/bin/perl
+
+###########################################################################
+#
+# kpcli - KeePass Command Line Interface
+#
+# Author: Lester Hightower <hightowe at cpan dot org>
+#
+# This program was inspired by "kedpm -c" and resulted despite illness
+# (or more likely because of it) over the USA Thanksgiving holiday in
+# late November of 2010. As a long-time user of the Ked Password Manager
+# I really missed a command line interface after getting an Android cell
+# phone and switching to KeePass, so that I could access my password
+# database on my phone. This program scratches that itch.
+#
+###########################################################################
+
+# The required perl modules
+use strict; # core
+use version; # core
+use File::Spec; # core
+use FileHandle; # core
+use Getopt::Long; # core
+use File::Basename; # core
+use Digest::file; # core
+use Digest::MD5; # core
+use Digest::SHA qw(sha256); # core
+use Data::Dumper qw(Dumper); # core
+use Term::ANSIColor; # core
+use Carp qw(longmess); # core
+use English qw(-no_match_vars); # core
+use Time::HiRes qw(gettimeofday tv_interval); # core
+use Time::Local qw(timegm); # core
+use Clone qw(clone); # core
+use POSIX; # core, required for unsafe signal handling
+use Crypt::Rijndael; # non-core, libcrypt-rijndael-perl on Ubuntu
+use Sort::Naturally; # non-core, libsort-naturally-perl on Ubuntu
+use Term::ReadKey; # non-core, libterm-readkey-perl on Ubuntu
+use Term::ShellUI; # non-core, libterm-shellui-perl on Ubuntu
+use File::KeePass 0.03; # non-core, libfile-keepass-perl on Ubuntu
+ # - >=v0.03 needed due critical bug fixes
+# Pull in optional perl modules with run-time loading
+my %OPTIONAL_PM=();
+# Data::Password is needed for the pwck command (check password quality).
+if (runtime_load_module(\%OPTIONAL_PM,'Data::Password',[qw(IsBadPassword)])) {
+ no warnings 'once';
+ $Data::Password::MINLEN = 8;
+ $Data::Password::MAXLEN = 0;
+}
+# Capture::Tiny is needed to safely optionally-load Clipboard.
+# Clipboard is needed by the clipboard copy commands (xw, xu, xp, and xx).
+if (runtime_load_module(\%OPTIONAL_PM,'Capture::Tiny',[qw(capture)])) {
+ # Clipboard tests its dependencies at import() and writes warnings to STDERR.
+ # Tiny::Capture is used to catch those warnings and we silently hold them
+ # until and unless someone tries to use dependant functions.
+ my ($out, $err, @result) = capture(
+ sub { runtime_load_module(\%OPTIONAL_PM,'Clipboard',undef); } );
+ if (length($err)) {
+ # Cleanup the error message for for better viewing by the user
+ $err =~ s/^\s+//g; $err =~ s/\s+$//g; $err =~ s/^(.*)$/ > $1/mg;
+ $OPTIONAL_PM{'Clipboard'}->{error} = $err;
+ }
+} else {
+ # If we didn't get Capture::Tiny, also mark Clipboard as not loaded.
+ $OPTIONAL_PM{'Clipboard'}->{loaded} = 0;
+}
+# Win32::Console::ANSI is needed to emulate ANSI colors on Windows
+if (lc($OSNAME) =~ m/^mswin/) {
+ if (! runtime_load_module(\%OPTIONAL_PM,'Win32::Console::ANSI',undef)) {
+ # If we don't have Win32::Console::ANSI then we want to override
+ # &main::color() and colored() from Term::ANSIColor with NOOPs.
+ no strict 'refs';
+ *color = sub { my $color = shift @_; return ''; };
+ *colored = sub { my $color = shift @_; my $text=shift @_; return $text; };
+ }
+}
+runtime_load_module(\%OPTIONAL_PM,'Sub::Install',undef);
+
+$|=1; # flush immediately after writes or prints to STDOUT
+
+my $DEBUG=0;
+$Data::Dumper::Useqq = 1; # Have Dumper escape special chars (like \0)
+my $DEFAULT_PASSWD_LEN = 20; # Default length of generated passwords.
+my $DEFAULT_PASSWD_MIN = 1; # Minimum length of generated passwords.
+my $DEFAULT_PASSWD_MAX = 50; # Maximum length of generated passwords.
+my $DEFAULT_ENTRY_ICON = 0; # In keepassx, icon 0 is a golden key
+my $DEfAULT_GROUP_ICON = 49; # In keepassx, icon 49 is an opened file folder
+my $DEfAULT_BAKUP_ICON = 2; # In keepassx, icon 2 is a warning sign
+my $FOUND_DIR = '_found'; # The find command's results go in /_found/
+my $MAX_ATTACH_SIZE = 2*1024**2; # Maximum size of entry file attachments
+
+# Application name and version
+my $APP_NAME = basename($0); $APP_NAME =~ s/\.(pl|exe)$//;
+my $VERSION = "2.8";
+
+our $HISTORY_FILE = ""; # Gets set in the MyGetOpts() function
+my $opts=MyGetOpts(); # Will only return with options we think we can use
+
+my $doc_passwd_gen =
+ "For password generation, the \"g\" method produces a\n" .
+ "string of random characters, the \"w\" method creates a\n" .
+ "4-word string inspired by \"correct horse battery staple\"\n" .
+ "(http://xkcd.com/936/), and the \"i\" method provides an\n" .
+ "interactive user interface to the \"g\" and \"w\" methods.\n" .
+ "\n" .
+ "By default, the \"g\" and \"i\" methods generate passwords that\n" .
+ "are $DEFAULT_PASSWD_LEN characters long. " .
+ "That can be controlled by providing an\n" .
+ "integer immediately after the \"g|i\" in the range of "
+ . "$DEFAULT_PASSWD_MIN-$DEFAULT_PASSWD_MAX.\n" .
+ "For example, \"g17\" will generate a 17 character password.\n" .
+ "";
+# Setup our Term::ShellUI object
+my $term = new Term::ShellUI(
+ app => $APP_NAME,
+ term => get_readline_term(\%OPTIONAL_PM, $APP_NAME),
+ history_file => $HISTORY_FILE,
+ keep_quotes => 0,
+ commands => {
+ "ver" => {
+ desc => "Print the version of this program",
+ doc => "\n" .
+ "Add the -v option to get an inventory of the versions\n" .
+ "of the various dependencies being used. Please provide\n" .
+ "that information in any bug reports filed.\n" .
+ "",
+ method => \&cli_version,
+ minargs => 0, maxargs => 1,
+ exclude_from_history => 1,
+ timeout_exempt => 1,
+ },
+ "version" => { alias => "ver",
+ exclude_from_completion=>1, exclude_from_history => 1,
+ timeout_exempt => 1,
+ },
+ "vers" => {
+ desc => "Same as \"ver -v\"",
+ minargs => 0, maxargs => 0,
+ method => sub { cli_version(shift, { args => ['-v'] }); },
+ exclude_from_completion=>1, exclude_from_history => 1,
+ timeout_exempt => 1,
+ },
+ "versions" => { alias => "vers",
+ exclude_from_completion=>1, exclude_from_history => 1,
+ timeout_exempt => 1,
+ },
+ "help" => {
+ desc => "Print helpful information",
+ args => sub { shift->help_args(undef, @_); },
+ method => sub { my_help_call(@_); },
+ exclude_from_history => 1,
+ timeout_exempt => 1,
+ #method => sub { shift->help_call(undef, @_); }
+ },
+ "h" => { alias => "help",
+ exclude_from_completion=>1, exclude_from_history => 1,
+ timeout_exempt => 1,
+ },
+ "?" => { alias => "help",
+ exclude_from_completion=>1, exclude_from_history => 1,
+ timeout_exempt => 1,
+ },
+ "cls" => {
+ desc => 'Clear screen ("clear" command also works)',
+ doc => "\n" .
+ "Clear the screen, which is useful when guests arrive.\n",
+ maxargs => 0,
+ method => \&cli_cls,
+ exclude_from_history => 1,
+ timeout_exempt => 1,
+ },
+ "clear" => { alias => "cls", exclude_from_history => 1,
+ timeout_exempt => 1, },
+ "quit" => {
+ desc => "Quit this program (EOF and exit also work)",
+ maxargs => 0,
+ method => sub { run_no_TSTP(\&cli_quit, @_); },
+ exclude_from_history => 1,
+ timeout_exempt => 1,
+ },
+ "exit" => { alias => "quit", exclude_from_history => 1,
+ timeout_exempt => 1, },
+ # Generally, commands above here are timeout_exempt
+ #"" => { args => sub { shift->complete_history(@_) } },
+ "history" => { desc => "Prints the command history",
+ doc => "\nSpecify a number to list the last N lines of history.\n" .
+ "Pass -c to clear the command history.\n" .
+ "Pass -d NUM to delete a single item.\n",
+ args => "[-c] [-d] [number]",
+ method => sub { shift->history_call(@_) },
+ exclude_from_history => 1,
+ },
+ "cd" => {
+ desc => "Change directory (path to a group)",
+ doc => "\n" .
+ "Change the pwd to an absolute or relative path.\n" .
+ "Slashes in names are escaped with backslashes:\n" .
+ "(i.e. \"cd /personal/Comcast\\/Xfinity\").\n",
+ maxargs => 1,
+ args => \&complete_groups,
+ method => \&cli_cd,
+ },
+ "chdir" => { alias => 'cd' },
+ "cl" => {
+ desc => "Change directory and list entries (cd+ls)",
+ doc => "\n" .
+ "Change the pwd to an absolute or relative path\n" .
+ "and list the entries there. This is a useful way\n" .
+ "to quickly navigate to a path and have the entries\n" .
+ "listed in preparation to run the show command.\n",
+ maxargs => 1,
+ args => \&complete_groups,
+ method => sub { if(cli_cd(@_) == 0) { cli_ls() } },
+ },
+ "saveas" => {
+ desc => "Save to a specific filename " .
+ "(saveas <file.kdb> [<file.key>])",
+ minargs => 1, maxargs => 2,
+ args => [\&Term::ShellUI::complete_files,
+ \&Term::ShellUI::complete_files],
+ proc => sub { run_no_TSTP(\&cli_saveas, @_); },
+ },
+ "export" => {
+ desc => "Export entries to a new KeePass DB " .
+ "(export <file.kdb> [<file.key>])",
+ doc => "\n" .
+ "Use this command to export the full tree of groups\n" .
+ "and entries to another KeePass database file on disk,\n" .
+ "starting at your current path (pwd).\n" .
+ "\n" .
+ "This is also a \"safer\" way to change your database\n" .
+ "password. Export from /, verify that the new file is\n" .
+ "good, and then remove your original file.\n",
+ minargs => 1, maxargs => 2,
+ args => [\&Term::ShellUI::complete_files,
+ \&Term::ShellUI::complete_files],
+ proc => sub { run_no_TSTP(\&cli_export, @_); },
+ },
+ "import" => {
+ desc => "Import another KeePass DB " .
+ "(import <file.kdb> <path> [<file.key>])",
+ doc => "\n" .
+ "Use this command to import the entire KeePass DB\n" .
+ "specified by <file.kdb> into a new group at <path>.\n",
+ minargs => 2, maxargs => 3,
+ args => [\&Term::ShellUI::complete_files,\&complete_groups,
+ \&Term::ShellUI::complete_files],
+ proc => sub { run_no_TSTP(\&cli_import, @_); },
+ },
+ "open" => {
+ desc => "Open a KeePass database file " .
+ "(open <file.kdb> [<file.key>])",
+ minargs => 1, maxargs => 2,
+ args => [\&Term::ShellUI::complete_files,
+ \&Term::ShellUI::complete_files],
+ proc => sub { run_no_TSTP(\&cli_open, @_); },
+ },
+ "mkdir" => {
+ desc => "Create a new group (mkdir <group_name>)",
+ minargs => 1, maxargs => 1,
+ args => \&complete_groups,
+ method => \&cli_mkdir,
+ },
+ "rmdir" => {
+ desc => "Delete a group (rmdir <group_name>)",
+ minargs => 1, maxargs => 1,
+ args => \&complete_groups,
+ method => \&cli_rmdir,
+ },
+ "dir" => { alias => "ls", },
+ "ls" => {
+ desc => "Lists items in the pwd or specified paths " .
+ "(\"dir\" also works)",
+ minargs => 0, maxargs => 99,
+ args => \&complete_groups_and_entries,
+ method => \&cli_ls,
+ },
+ "new" => {
+ desc => "Create a new entry: new <optional path&|title>",
+ doc => "\n" .
+ "The new command is used to create a new entry.\n" .
+ "\n" .
+ "Usage is straightforward.\n" .
+ "\n" .
+ $doc_passwd_gen .
+ "",
+ minargs => 0, maxargs => 1,
+ args => [\&complete_groups],
+ method => sub { run_no_TSTP(\&cli_new, @_); },
+ },
+ "rm" => {
+ desc => "Remove an entry: rm <path to entry|entry number>",
+ minargs => 1, maxargs => 1,
+ args => \&complete_groups_and_entries,
+ method => \&cli_rm,
+ },
+ "xu" => {
+ desc => "Copy username to clipboard: xu <entry path|number>",
+ minargs => 1, maxargs => 1,
+ args => \&complete_groups_and_entries,
+ method => sub { cli_xN('xu', @_); }
+ },
+ "xw" => {
+ desc => "Copy URL (www) to clipboard: xw <entry path|number>",
+ minargs => 1, maxargs => 1,
+ args => \&complete_groups_and_entries,
+ method => sub { cli_xN('xw', @_); }
+ },
+ "xp" => {
+ desc => "Copy password to clipboard: xp <entry path|number>",
+ minargs => 1, maxargs => 1,
+ args => \&complete_groups_and_entries,
+ method => sub { cli_xN('xp', @_); }
+ },
+ "xx" => {
+ desc => "Clear the clipboard: xx",
+ minargs => 0, maxargs => 0,
+ method => sub { cli_xN('xx'); }
+ },
+ "pwck" => {
+ desc => "Check password quality: pwck <entry|group>",
+ doc => "\n" .
+ "The pwck command test password quality for entries.\n" .
+ "You can check an individual entry or all entries inside\n" .
+ "of a group, recursively. To check every password in your\n" .
+ "database, use: pwck /\n" .
+ "",
+ minargs => 1, maxargs => 1,
+ args => \&complete_groups_and_entries,
+ method => \&cli_pwck,
+ },
+ "stats" => {
+ desc => "Prints statistics about the open KeePass file",
+ method => \&cli_stats,
+ },
+ "show" => {
+ desc => "Show an entry: show [-f] [-a] <entry path|entry number>",
+ doc => "\n" .
+ "The show command tries to intelligently determine\n" .
+ "what you want to see and to make it easy to display.\n" .
+ "Show can take a path to an entry as its argument or\n" .
+ "an entry number as shown by the ls command.\n" .
+ "\n" .
+ "When using entry numbers, they will refer to the last\n" .
+ "path when an ls was performed or pwd if ls has not\n" .
+ "yet been run.\n" .
+ "\n" .
+ "By default, passwords are \"hidden\" by being displayed as\n" .
+ "\"red on red\" where they can be copied to the clip board\n" .
+ "but not seen. Provide the -f option to show passwords.\n" .
+ "Use the -a option to see create and modified times, and\n" .
+ "the index of the icon set for the entry.\n" .
+ "",
+ minargs => 1, maxargs => 3,
+ args => \&complete_groups_and_entries,
+ method => \&cli_show,
+ },
+ "edit" => {
+ desc => "Edit an entry: edit <path to entry|entry number>",
+ doc => "\n" .
+ "The edit command is used to modify an entry.\n" .
+ "\n" .
+ "Usage is straightforward.\n" .
+ "\n" .
+ $doc_passwd_gen .
+ "",
+ minargs => 1, maxargs => 1,
+ args => \&complete_groups_and_entries,
+ method => sub { run_no_TSTP(\&cli_edit, @_); },
+ },
+ "attach" => {
+ desc => "Manage attachments: attach <path to entry|entry number>",
+ doc => "\n" .
+ "The attach command provided an interactive user interface\n" .
+ "for managing file attachments on an entry.\n" .
+ "",
+ minargs => 1, maxargs => 1,
+ args => \&complete_groups_and_entries,
+ method => sub { run_no_TSTP(\&cli_attach, @_); },
+ },
+ "mv" => {
+ desc => "Move an item: mv <path to a group|or entries> <path to group>",
+ minargs => 2, maxargs => 2,
+ args => [\&complete_groups_and_entries, \&complete_groups],
+ method => \&cli_mv,
+ },
+ "rename" => {
+ desc => "Rename a group: rename <path to group>",
+ minargs => 1, maxargs => 1,
+ args => \&complete_groups,
+ method => \&cli_rename,
+ },
+ "copy" => {
+ desc => "Copy an entry: copy <path to entry> <path to new entry>",
+ minargs => 2, maxargs => 2,
+ args => [\&complete_groups_and_entries,
+ \&complete_groups_and_entries],
+ method => \&cli_copy,
+ },
+ "cp" => { alias => "copy", },
+ "clone" => {
+ desc =>"Clone an entry: clone <path to entry> <path to new entry>",
+ doc => "\n" .
+ "Clones an entry for you to edit. Similar to doing\n" .
+ "\"cp foo bar; edit bar\" if that were possible.\n" .
+ "\n",
+ minargs => 2, maxargs => 2,
+ args => [\&complete_groups_and_entries,
+ \&complete_groups_and_entries],
+ method => sub { run_no_TSTP(\&cli_clone, @_); },
+ },
+ "save" => {
+ desc => "Save the database to disk",
+ minargs => 0, maxargs => 0, args => "",
+ method => sub { run_no_TSTP(\&cli_save, @_); },
+ },
+ "close" => {
+ desc => "Close the currently opened database",
+ minargs => 0, maxargs => 0, args => "",
+ method => sub { run_no_TSTP(\&cli_close, @_); },
+ },
+ "find" => {
+ desc => "Finds entries by Title",
+ doc => "\n" .
+ "Searches for entries with the given search term\n" .
+ "in their title and places matches into \"/$FOUND_DIR/\".\n" .
+ "\n" .
+ "Add -a to search data fields beyond just the title.\n" .
+ "\n" .
+ "Use -expired to find expired entries.\n",
+ minargs => 1, maxargs => 2, args => "<search string>",
+ method => \&cli_find,
+ },
+ "pwd" => {
+ desc => "Print the current working directory",
+ maxargs => 0, proc => \&cli_pwd,
+ },
+ "icons" => {
+ desc => "Change group or entry icons in the database",
+ maxargs => 0,
+ proc => sub { run_no_TSTP(\&cli_icons, @_); },
+ },
+ },
+ );
+$term->prompt(\&term_set_prompt);
+
+# Seed our state global variable
+our $state={
+ 'appname' => $APP_NAME,
+ 'term' => $term,
+ 'OPTIONAL_PM' => \%OPTIONAL_PM,
+ 'kdb_has_changed' => 0,
+ 'last_ls_ents' => [], # Array of entries last listed to the user.
+ 'put_master_passwd' => \&put_master_passwd,
+ 'get_master_passwd' => \&get_master_passwd,
+ 'last_activity_time' => 0, # initilized by setup_timeout_handling()
+ };
+# If given --kdb=, open that file
+if (length($opts->{kdb})) {
+ my $err = open_kdb($opts->{kdb}, $opts->{key}); # Sets $state->{'kdb'}
+ if (length($err)) {
+ print "Error opening file: $err\n";
+ }
+} else {
+ new_kdb($state);
+}
+
+# Enter the interative kpcli shell session
+print "\n" .
+ "KeePass CLI ($APP_NAME) v$VERSION is ready for operation.\n" .
+ "Type 'help' for a description of available commands.\n" .
+ "Type 'help <command>' for details on individual commands.\n";
+if ($DEBUG) {print 'Using '.$term->{term}->ReadLine." for readline.\n"; }
+if ( (! $DEBUG) && (lc($OSNAME) !~ m/^mswin/) &&
+ ($term->{term}->ReadLine ne 'Term::ReadLine::Gnu')) {
+ print color('yellow') . "\n" .
+ "* NOTE: You are using " . $term->{term}->ReadLine . ".\n" .
+ " Term::ReadLine::Gnu will provide better functionality.\n" .
+ color('clear');
+}
+# My patch made it into Term::ShellUI v0.9, but I still chose not to make
+# this program demand >=0.9 and instead look for the add_eof_exit_hook()
+# and use it if it exists and warn if not.
+if (Term::ShellUI->can('add_eof_exit_hook')) {
+ $term->add_eof_exit_hook(\&eof_exit_hook);
+} else {
+ warn "* Please upgrade Term::ShellUI to version 0.9 or newer.\n";
+}
+print "\n";
+
+setup_signal_handling(); # Exactly what the name indicates...
+
+# Setup the inactivity timeout feature (--timeout).
+if (defined($opts->{timeout}) && int($opts->{timeout}) > 0) {
+ if (! $state->{OPTIONAL_PM}->{'Sub::Install'}->{loaded}) {
+ print "Error: --timeout requires the Sub::Install module.\n";
+ exit;
+ }
+ setup_timeout_handling();
+}
+
+$term->run();
+
+exit;
+
+############################################################################
+############################################################################
+############################################################################
+
+sub open_kdb {
+ my $file=shift @_;
+ my $key_file=shift @_;
+ our $state;
+
+ # Make sure the file exists, is readable, and is a keepass file
+ if (! -f $file) {
+ return "File does not exist: $file";
+ }
+ if (! -r $file) {
+ return "File is not readable: $file";
+ }
+ if (magic_file_type($file) ne 'keepass') {
+ return "Does not appear to be a KeePass file: $file";
+ }
+
+ # Look for lock file and warn if it is found
+ my $lock_file = $file . '.lock'; # KeePassX style
+ if (-f $lock_file &&
+ ! (defined($opts->{readonly}) && int($opts->{readonly})) ) {
+ print color('bold yellow') .
+ "WARNING:" .
+ color('clear') . color('red') .
+ " A KeePassX-style lock file is in place for this file.\n" .
+ " It may be opened elsewhere." .
+ " " . color('bold yellow') . "Be careful of saving!\n" .
+ color('clear');
+ } else {
+ $state->{placed_lock_file} = $lock_file;
+ }
+
+ # Ask the user for the master password and then open the kdb
+ my $master_pass=GetMasterPasswd();
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+ $state->{kdb} = File::KeePass->new;
+ if (! eval { $state->{kdb}->load_db($file,
+ composite_master_pass($master_pass, $key_file)) }) {
+ die "Couldn't load the file $file: $@";
+ }
+
+ if ($state->{placed_lock_file}) {
+ touch_file($state->{placed_lock_file});
+ }
+
+ # We hold a read file handle open for no reason other than
+ # to show up in lsof.
+ if (defined($state->{kdb_file_handle})) {
+ close $state->{kdb_file_handle};
+ }
+ $state->{kdb_file_handle} = new FileHandle;
+ open($state->{kdb_file_handle}, '<', $file);
+
+ $state->{kdb_file} = $file;
+ $state->{key_file} = $key_file;
+ $state->{kdb_ver} = $state->{kdb}->{header}->{version}; # will be 1 or 2
+ $state->{put_master_passwd}($master_pass);
+ $state->{kdb_has_changed}=0;
+ $master_pass="\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
+
+ # Build the %all_grp_paths_fwd and %all_grp_paths_rev structures
+ refresh_state_all_paths();
+
+ # Store the md5sum of the file so we can watch for unexpected changes
+ $state->{kdb_file_md5} = Digest::file::digest_file_hex($file, "MD5");
+
+ # Initialize our state into "/"
+ cli_cd($term, {'args' => ["/"]});
+
+ return ''; # If we return anything else it is an error message
+}
+
+# Called by Term::ShellUI to get the user prompt
+sub term_set_prompt($$) {
+ my $term=shift @_;
+ my $raw_cmd=shift @_;
+ our $state;
+
+ my $app=$state->{appname};
+ my $pwd=get_pwd();
+ return "$app:$pwd> ";
+}
+
+# Walks through a tree of groups building a flat hash of NULL-separated
+# paths to group IDs. Called on the root to build a full path tree.
+sub build_all_group_paths {
+ my $hash = shift @_;
+ my $g = shift @_;
+ my $root_path = shift @_ || [];
+
+ foreach my $me (@{$g}) {
+ my @path_to_me = @{$root_path};
+ push @path_to_me, $me->{title};
+ my $path=join("\0", at path_to_me);
+ my $err_path = '/' . humanize_path($path);
+ if (defined($hash->{$path})) {
+ print color('bold yellow') . "WARNING: " . color('clear') .
+ color('red') . "Multiple groups titled: $err_path!\n" .
+ color('red') . "This is unsupported and may cause data loss!\n" .
+ color('clear');
+ }
+ $hash->{$path}=$me->{id};
+
+ if (defined($me->{groups})) {
+ build_all_group_paths($hash,$me->{groups},\@path_to_me);
+ }
+ }
+ return (scalar(keys(%{$hash})));
+}
+
+# Walks through a tree of groups building a flat hash of NULL-separated
+# paths to entry IDs. Called on the root to build a full path tree.
+sub build_all_entry_paths {
+ my $hash = shift @_;
+ my $g = shift @_;
+ my $root_path = shift @_ || [];
+
+ my $red=color('red');
+
+ foreach my $me (@{$g}) {
+ my @path_to_me = @{$root_path};
+ push @path_to_me, $me->{title};
+ if (defined($me->{entries})) {
+ ENTRY: foreach my $ent (@{$me->{entries}}) {
+ if ($ent->{'title'} eq 'Meta-Info' && $ent->{'username'} eq 'SYSTEM') {
+ next ENTRY; # skip Meta-Info/SYSTEM entries
+ }
+ my $path=join( "\0", (@path_to_me, $ent->{title}) );
+ my $err_path = '/' . humanize_path($path);
+ if ($ent->{title} eq '') {
+ print color('bold yellow') . "WARNING: " . color('clear') .
+ $red . "There is an entry with a blank title in $err_path!\n" .
+ color('clear');
+ }
+ if (defined($hash->{$path}) &&
+ $err_path !~ m/\/Backup\/|\/Meta-Info$/) {
+ print color('bold yellow') . "WARNING: " . color('clear') .
+ $red . "Multiple entries titled: $err_path!\n" .
+ $red . "This is unsupported and may cause data loss!\n" .
+ color('clear');
+ }
+ $hash->{$path}=$ent->{id};
+ }
+ }
+
+ if (defined($me->{groups})) {
+ build_all_entry_paths($hash,$me->{groups},\@path_to_me);
+ }
+ }
+ return (scalar(keys(%{$hash})));
+}
+
+# Returns the current path the user is sitting in.
+sub get_pwd {
+ my $pwd='';
+ if (defined($state->{all_grp_paths_rev}->{$state->{path}->{id}})) {
+ $pwd=$state->{all_grp_paths_rev}->{$state->{path}->{id}};
+ }
+ $pwd =~ s%/%\\/%g;
+ $pwd =~ s/\0/\//g;
+ $pwd = '/' . $pwd;
+return $pwd;
+}
+
+# Destroys our /_found group (where we place search results)
+sub destroy_found {
+ our $state;
+ # Look for an exising /_found and kill it if it exists
+ my $k=$state->{kdb};
+ my $found_group=$k->find_group({level=>0,title=>$FOUND_DIR});
+ if (defined($found_group)) {
+ my @oldents = $k->find_entries({group=>$found_group->{id}});
+ foreach my $ent (@oldents) {
+ $k->delete_entry({id => $ent->{id}});
+ }
+ $k->delete_group({level=>0,title=>$FOUND_DIR});
+
+ # Because we destroyed /_found we must refresh our $state paths
+ refresh_state_all_paths();
+ }
+}
+
+# Refreshes $state->{all_grp_paths_fwd} and $state->{all_grp_paths_rev}
+sub refresh_state_all_paths() {
+ our $state;
+
+ # Build all group paths
+ my %all_grp_paths_fwd;
+ build_all_group_paths(\%all_grp_paths_fwd,$state->{kdb}->groups);
+ my %all_grp_paths_rev = reverse %all_grp_paths_fwd;
+ $state->{all_grp_paths_fwd}=\%all_grp_paths_fwd;
+ $state->{all_grp_paths_rev}=\%all_grp_paths_rev;
+
+ # Build all entry paths
+ my %all_ent_paths_fwd;
+ build_all_entry_paths(\%all_ent_paths_fwd,$state->{kdb}->groups);
+ my %all_ent_paths_rev = reverse %all_ent_paths_fwd;
+ $state->{all_ent_paths_fwd}=\%all_ent_paths_fwd;
+ $state->{all_ent_paths_rev}=\%all_ent_paths_rev;
+}
+
+# Gathers the list of groups and entries for the pwd we're sitting in
+sub get_current_groups_and_entries {
+ return get_groups_and_entries(get_pwd());
+}
+sub get_groups_and_entries {
+ my $path=shift @_;
+ our $state;
+
+ my $k=$state->{kdb};
+
+ # Collect the @groups and entries
+ my @groups=();
+ my @entries=();
+ my $norm_path = normalize_path_string($path);
+ if (length($norm_path) < 1) {
+ @groups = $k->find_groups({level=>0});
+ @entries = $k->find_entries({level => 0});
+ } else {
+ my $id=$state->{all_grp_paths_fwd}->{$norm_path};
+ my ($this_grp, at trash) = $k->find_groups({id=>$id});
+ if (defined($this_grp->{groups})) { # subgroups
+ @groups = @{$this_grp->{groups}};
+ }
+ @entries = $k->find_entries({group_id => $id});
+ }
+
+ # Remove Meta-Info/SYSTEM entries
+ my @non_meta_info = ();
+ foreach my $ent (@entries) {
+ if (!($ent->{'title'} eq 'Meta-Info' && $ent->{'username'} eq 'SYSTEM')) {
+ push @non_meta_info, $ent;
+ }
+ }
+ @entries = @non_meta_info;
+
+ # Sort the results
+ @groups = sort group_sort @groups;
+ @entries = sort { ncmp($a->{title},$b->{title}); } @entries;
+
+ return (\@groups,\@entries);
+}
+
+# This function takes a group ID and returns all of the child
+# groups of that group, flattened.
+sub all_child_groups_flattened {
+ my $group_id=shift @_;
+ our $state;
+
+ my $k=$state->{kdb};
+ my @groups=();
+ my ($this_grp, at trash) = $k->find_groups({id=>$group_id});
+ if (defined($this_grp->{groups})) { # subgroups
+ @groups = @{$this_grp->{groups}};
+ foreach my $child_group (@groups) {
+ push @groups, all_child_groups_flattened($child_group->{id});
+ }
+ }
+
+ return @groups;
+}
+
+# A function to properly sort groups by title
+sub group_sort($$) {
+ my $a=shift @_;
+ my $b=shift @_;
+
+ # _found at level 0 is a special case (from our find command).
+ if ($a->{title} eq $FOUND_DIR && $a->{level} == 0) {
+ return 1;
+ } elsif ($b->{title} eq $FOUND_DIR && $b->{level} == 0) {
+ return -1;
+ # Backup at level=0 is a special case (KeePassX's Backup group).
+ } elsif ($a->{title} eq 'Backup' && $a->{level} == 0) {
+ return 1;
+ } elsif ($b->{title} eq 'Backup' && $b->{level} == 0) {
+ return -1;
+ # "Recycle Bin" at level=0 is a special case (KeePass v2).
+ } elsif ($a->{title} eq 'Recycle Bin' && $a->{level} == 0) {
+ return 1;
+ } elsif ($b->{title} eq 'Recycle Bin' && $b->{level} == 0) {
+ return -1;
+ # Sort everything else naturally (Sort::Naturally::ncmp).
+ } else {
+ return ncmp($a->{title},$b->{title}); # Natural sort
+ }
+}
+
+# -------------------------------------------------------------------------
+# All of the cli_*() functions are below here
+# -------------------------------------------------------------------------
+
+# A simple wrapper function to block SIGTSTP (^Z) during certain commands
+sub run_no_TSTP {
+ my $func = shift @_;
+ $SIG{TSTP}='IGNORE';
+ my @retval = &$func(@_);
+ $SIG{TSTP}='DEFAULT';
+ return @retval;
+}
+
+# Checks passwords for their quality
+sub cli_pwck {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ # If Data::Password is not avaiable we can't do this for the user
+ if (! $state->{OPTIONAL_PM}->{'Data::Password'}->{loaded}) {
+ print "Error: pwck requires the Data::Password module.\n";
+ return;
+ }
+
+ my @targets = ();
+ my $target = $params->{args}->[0];
+ # Start by trying to find a single entity with the paramter given.
+ # If no single entity is found then try to find entities based on
+ # assuming that the path given is a group.
+ my $ent=find_target_entity_by_number_or_path($target);
+ if (defined($ent)) {
+ push @targets, $ent;
+ } else {
+ my @groups = ();
+ my $target = normalize_path_string($target);
+ if ($target eq '' || $target eq '.' && get_pwd() eq '/') {
+ @groups = $state->{kdb}->find_groups({}); # Every group in the file!
+ } elsif (defined($state->{all_grp_paths_fwd}->{$target})) {
+ my $group_id = $state->{all_grp_paths_fwd}->{$target};
+ my $this_grp = $state->{kdb}->find_group( { id => $group_id } );
+ @groups = all_child_groups_flattened($group_id);
+ push @groups, $this_grp; # Push this group onto its children
+ }
+ # Loop over each target group adding each of its entries as targets
+ foreach my $group (@groups) {
+ if (defined($group->{entries})) {
+ push @targets, @{$group->{entries}};
+ }
+ }
+ }
+
+ # Test each password, collect the results and record empty passwords
+ my %results=();
+ my @empties = ();
+ print " working...\r";
+ my @busy_chars = qw(\ | / -); my $i=0; my $in=10;
+ foreach my $ent (@targets) {
+ printf "%s\r", $busy_chars[int($i/$in)%($#busy_chars+1)] if (!($i++ % $in));
+ my $pass = $state->{kdb}->locked_entry_password($ent);
+ if (length($pass) == 0) {
+ push @empties, $ent;
+ $results{$ent->{id}} = '';
+ } else {
+ $results{$ent->{id}} = IsBadPassword($pass);
+ if ($results{$ent->{id}} =~ m/dictionary word/i) {
+ # IsBadPassword() reports dictionary words that it finds. I don't
+ # like that from a security perspective so we change that here.
+ $results{$ent->{id}} = "contains a dictionary word";
+ }
+ }
+ # If the user hit ^C (SIGINT) then we need to stop
+ if (recent_sigint()) {
+ print "\r"; # Need to return to column 0 of the output line
+ return 0;
+ }
+ }
+
+ # If we only analyzed one password, return singular-style results
+ if (scalar(@targets) == 1) {
+ my $ent=$targets[0];
+ if (length($results{$ent->{id}})) {
+ print "Password concerns: " . $results{$ent->{id}} . "\n";
+ } elsif (scalar(@empties) > 0) {
+ print "Password field is empty.\n";
+ } else {
+ print "Password strength is good.\n";
+ }
+ } else {
+ # If we analyzed more than one password, return multiple-style results
+ my %problems=();
+ foreach my $ent_id (keys %results) {
+ if (length($results{$ent_id})) {
+ $problems{$state->{all_ent_paths_rev}->{$ent_id}} = $ent_id;
+ }
+ }
+ my $analyzed = scalar(@targets);
+ my $problem_count = scalar(keys %problems);
+ my $empty_count = scalar(@empties);
+ print "$analyzed passwords analyzed, $empty_count blank, " .
+ "$problem_count concerns found";
+ if ($problem_count > 0) { print ":"; } else { print "."; }
+ print "\n";
+ foreach my $path (sort keys %problems) {
+ print humanize_path($path) . ": $results{$problems{$path}}\n";
+ }
+ }
+
+ return 0;
+}
+
+# Prints some statistics about the open KeePass file
+sub cli_stats {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ # Group and entry counts
+ my %stats;
+ $stats{group_count} = scalar(keys(%{$state->{all_grp_paths_fwd}}));
+ $stats{entry_count} = scalar(keys(%{$state->{all_ent_paths_fwd}}));
+
+ # Password lengths
+ my $k=$state->{kdb};
+ my %password_lengths;
+ print " working...\r";
+ my @busy_chars = qw(\ | / -); my $i=0; my $in=100;
+ foreach my $ent_id (values(%{$state->{all_ent_paths_fwd}})) {
+ printf "%s\r", $busy_chars[int($i/$in)%($#busy_chars+1)] if (!($i++ % $in));
+ my $ent = $k->find_entry({id => $ent_id});
+ my $pass_len = length($k->locked_entry_password($ent));
+ if ($pass_len < 1) {
+ $password_lengths{"0"}++;
+ } elsif ($pass_len > 0 && $pass_len < 8) {
+ $password_lengths{"1-7"}++;
+ } elsif ($pass_len > 7 && $pass_len < 12) {
+ $password_lengths{"8-11"}++;
+ } elsif ($pass_len > 11 && $pass_len < 17) {
+ $password_lengths{"12-16"}++;
+ } elsif ($pass_len > 16 && $pass_len < 20) {
+ $password_lengths{"17-19"}++;
+ } elsif ($pass_len > 19) {
+ $password_lengths{"20+"}++;
+ }
+ # If the user hit ^C (SIGINT) then we need to stop
+ if (recent_sigint()) {
+ print " "x20 . "\r"; # Need to return to column 0 of the output line
+ return 0;
+ }
+ }
+
+ my $t= " "x20 . "\r" .
+ "File: " . $state->{kdb_file} . "\n" .
+ "Key file: " .
+ (defined($state->{key_file}) ? $state->{key_file} : 'N/A') . "\n";
+ if (defined($k->{header}->{database_name})) {
+ $t.="Name: " . $k->{header}->{database_name} . "\n";
+ }
+ if (defined($k->{header}->{database_description})) {
+ my $desc = $k->{header}->{database_description};
+ $desc =~ s/[\r\n]/\n/g;
+ my @l = split(/\n/, $desc);
+ $t .= "Description:\n" . "| " . join("\n| ", @l) . "\n";
+ }
+ $t .= "KeePass file version: " . $k->{header}->{version} . "\n" .
+ "Encryption type: " . $k->{header}->{enc_type} . "\n" .
+ "Encryption rounds: " . $k->{header}->{rounds} . "\n";
+ if (defined($k->{header}->{cipher})) {
+ $t.="Cipher: $stats{cipher}\n";
+ }
+ if (defined($k->{header}->{compression})) {
+ $t.="Compression: $stats{compression}\n";
+ }
+ $t .= "Number of groups: $stats{group_count}\n" .
+ "Number of entries: $stats{entry_count}\n" .
+ "Entries with passwords of length:\n".stats_print(\%password_lengths) .
+ "\n" .
+ "";
+ print $t;
+}
+
+sub cli_cls {
+ if (lc($OSNAME) =~ m/^mswin/ &&
+ (! $OPTIONAL_PM{'Win32::Console::ANSI'}->{loaded})) {
+ system("cls");
+ } else {
+ print "\033[2J\033[0;0H";
+ $|=1; # Needed for MS Windows (Win32::Console::ANSI works w/this flush)
+ }
+}
+
+sub cli_pwd {
+ print get_pwd() . "\n";
+}
+
+sub cli_cd {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ my $raw_pathstr = $params->{args}->[0];
+ # "cd ."
+ if ($raw_pathstr =~ m/^[.]$/) {
+ return; # nothing to do
+ }
+ # "cd -"
+ if ($raw_pathstr =~ m/^[-]$/) {
+ return cli_cd($self, {'args' => [$state->{oldpwd}]});
+ }
+ # Everything else is handled by helpers
+ return cli_cd_helper($state,normalize_path_string($raw_pathstr));
+}
+
+# Takes a possibly wacky path with ".."s and such and normalizes it into a
+# NULL-separated path that can be used as a key into $state->{all_grp_paths_fwd}
+sub normalize_path_string($) {
+ my $path_string = shift @_;
+ our $state;
+
+ if ($path_string =~ m/\0/) {
+ warn "normalize_path_string(\"$path_string\"): path contains a NULL. Likely a bug.\nPlease report it at https://sourceforge.net/p/kpcli/bugs/!\n";
+ }
+
+ # Split the path into @path
+ # http://efreedom.com/Question/1-3588341/Implement-Escape-Sequence-Using-Split-Perl
+ my $delim="/";
+ my $escape="\\";
+ my @path = $path_string =~
+ /(?:\Q$delim\E|^)((?>(?:\Q$escape\E.|(?!\Q$delim\E).)*))/gs;
+ #/(?:\Q$delim\E|^)((?:\Q$escape\E.|(?!\Q$delim\E).)*+)/gs; # perl 5.10+
+ s/\Q$escape$delim\E/$delim/g for @path;
+ @path=grep(!/^$/, @path); # Drop meaningless (excess) deimiters (/foo//bar)
+
+ # This block handles absolute and relative paths
+ my $path_str='';
+ if ($path_string =~ m%^/%) { # Absolute path
+ $path_str=join("\0", at path);
+ } else { # Relative path
+ my $pwd=$state->{all_grp_paths_rev}->{$state->{path}->{id}};
+ my @nwd=split("\0", $pwd);
+ push @nwd, @path;
+ $path_str=join("\0", @nwd);
+ }
+
+ # We should now have a NULL-separated, fully qualified path and
+ # we just need to work on cleaning up things like "." and ".."
+ # Squash single dots
+ while ($path_str=~s/\0[.]\0|^[.]\0|\0[.]$/\0/) {}
+ # Kill ".." and their parents
+ while ($path_str=~s/[^\0]+\0+[.][.]//) {}
+ $path_str=~s/\0\0+/\0/g; # squash any adjacent delimeters
+ $path_str=~s/^\0+//; # squash any leading delimeters
+ $path_str=~s/\0+$//; # squash any trailing delimeters
+
+ return $path_str;
+}
+
+sub cli_cd_helper($$) {
+ my $state=shift @_;
+ my $path_str=shift @_;
+ if (defined($state->{all_grp_paths_fwd}->{$path_str})) {
+ $state->{oldpwd}=get_pwd();
+ my $id=$state->{all_grp_paths_fwd}->{$path_str};
+ $state->{path}->{id}=$id;
+ return 0;
+ } elsif ($path_str eq '') { # cd /
+ $state->{oldpwd}=get_pwd();
+ delete $state->{path};
+ return 0;
+ } else {
+ print "Invalid path\n";
+ return -1;
+ }
+}
+
+# Return 1 if entry's password never expires, else 0.
+# Helps support both v1 (*.kdb) and v2 (*.kdbx) files.
+sub does_expire_never {
+ my $kdb_ver = shift @_;
+ my $ent = shift @_;
+ my $expires = $ent->{expires};
+ if ($kdb_ver == 1 && $expires =~ m/^(\d{4})-/ && ($1 == 2999)) {
+ return 1;
+ } elsif ($kdb_ver == 2 && (! $ent->{expires_enabled})) {
+ return 1;
+ }
+ return 0;
+}
+
+# Helper function of cli_find() to find expired entries.
+sub find_expired_entries {
+ my $kdb_ver = shift @_;
+ my $k = shift @_;
+ our $state;
+
+ my @expired = ();
+ my @all_entries_flattened = $k->find_entries({});
+ ENT: foreach my $ent (@all_entries_flattened) {
+ if (recent_sigint()) { return (); } # Bail on SIGINT
+ # If we don't have this entry recorded in all_ent_paths_rev then
+ # we can't report on it. The only case known of is when we have
+ # multiple entries at the same level (unsupported) and typically
+ # that's only seen in /Backup in v1 databases.
+ if (! defined($state->{all_ent_paths_rev}->{$ent->{id}})) {
+ next ENT;
+ }
+ # Now test the expired time...
+ if (does_expire_never($kdb_ver, $ent)) { next ENT; }
+ my $exp = $ent->{expires};
+ my $title = $ent->{title};
+ if ($exp !~ m/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/) {
+ print "Warning: Invalid expiration date found in \"$title\"\n";
+ next ENT;
+ }
+ my ($year,$mon,$mday,$hour,$min,$sec) = ($1, $2, $3, $4, $5, $6);
+ my $timegm = timegm($sec,$min,$hour,$mday,$mon-1,$year);
+ if ($timegm < time) {
+ push @expired, $ent;
+ }
+ }
+ return @expired;
+}
+
+sub cli_find($) {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ destroy_found();
+
+ # Users can provide a -a option to search more than just the title. We
+ # use GetOpts to parse this command line.
+ my $search_str='';
+ my %opts=();
+ {
+ local @ARGV = @{$params->{args}};
+ my $result = &GetOptions(\%opts, 'a', 'expired');
+ if (defined($opts{'expired'})) {
+ if (scalar(@ARGV) || scalar(keys(%opts)) > 1) {
+ print "-expired cannot have search criteria other qualifiers.\n";
+ return;
+ }
+ } else {
+ if (scalar(@ARGV) != 1) {
+ print "Find only supports searching for one word.\n";
+ return;
+ }
+ $search_str = $ARGV[0];
+ }
+ }
+
+ my @e = ();
+ my $k=$state->{kdb};
+
+ if (length($search_str)) {
+ print "Searching for \"$search_str\" ...\n";
+
+ # Make $search_str a case-insensitive regex
+ my @letters=split(//, $search_str);
+ foreach my $l (@letters) {
+ if (uc($l) ne lc($l)) {
+ $l='[' . uc($l) . lc($l) . ']';
+ }
+ }
+ $search_str=join('', @letters);
+
+ my @srch_flds = qw(title);
+ if ($opts{'a'}) {
+ @srch_flds = qw(title username comment url tags);
+ }
+ foreach my $fld (@srch_flds) {
+ # Search entries by title, skipping the /Backup (*.kdb) and/or
+ # "/Recycle Bin" groups if they exists
+ my $search_params = { "$fld =~" => $search_str };
+ foreach my $bu_dir ('/Backup','/Recycle Bin') {
+ my $backup_dir_normalized=normalize_path_string($bu_dir);
+ if (defined($state->{all_grp_paths_fwd}->{$backup_dir_normalized})) {
+ $search_params->{'group_id !'} =
+ $state->{all_grp_paths_fwd}->{$backup_dir_normalized};
+ }
+ }
+ push @e, $k->find_entries($search_params);
+ }
+ } elsif (defined($opts{'expired'})) {
+ @e = find_expired_entries($state->{kdb_ver}, $k);
+ }
+
+ # Remove Meta-Info/SYSTEM entries
+ my @non_meta_info = ();
+ foreach my $ent (@e) {
+ if (!($ent->{'title'} eq 'Meta-Info' && $ent->{'username'} eq 'SYSTEM')) {
+ push @non_meta_info, $ent;
+ }
+ }
+ @e = @non_meta_info;
+
+ if ( scalar(@e) < 1) {
+ print "No matches.\n";
+ return;
+ }
+
+ # If we get this far we have results to add to a new /_found
+ my $found_group = $k->add_group({title => $FOUND_DIR}); # root level group
+ my $found_gid = $found_group->{'id'};
+ $k->unlock;
+ my @matches=();
+ my %duplicates=();
+ FINDS: foreach my $ent (@e) {
+ my %new_ent = %{clone($ent)}; # Clone the entity
+ $new_ent{id} = int(rand(1000000000000000)); # A random new id
+ $new_ent{group} = $found_gid; # Place this entry clone into /_found
+ # $new_ent{path} is _NOT_ a normal key for File::KeePass but this is
+ # safe because we are adding it to entries in the /_found group which
+ # will not be saved to a file.
+ my $nulled_path=$state->{all_ent_paths_rev}->{$ent->{id}};
+ $new_ent{full_path} = '/' . humanize_path($nulled_path);
+ $new_ent{path} = dirname($new_ent{full_path}) . '/';
+ if (defined($duplicates{$new_ent{full_path}})) { next FINDS; }
+ $duplicates{$new_ent{full_path}} = 1;
+ $k->add_entry(\%new_ent);
+ push(@matches, \%new_ent);
+ # If the user hit ^C (SIGINT) then we need to stop
+ if (recent_sigint()) {
+ # If we delete the $FOUND_DIR group it should be safe to leave
+ $k->delete_group({title => $FOUND_DIR});
+ return undef;
+ }
+ }
+ $k->lock;
+
+ # Because we added a new /_found we must refresh our $state paths
+ refresh_state_all_paths();
+
+ # Tell the user what we found
+ print " - ".scalar(@matches)." matches found and placed into /$FOUND_DIR/\n";
+
+ # If we only found one, ask the user if they want to see it
+ if (scalar(@matches) == 1) {
+ print "Would you like to show this entry? [y/N] ";
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) eq 'y') {
+ my $search_params = { 'group_id =' => $found_gid };
+ my ($e, at empty) = $k->find_entries($search_params);
+ my $full_path="/$FOUND_DIR/" . $e->{title};
+ my $show_args = [ $full_path ];
+ # If we are doing an expired password search, find only one entry,
+ # and give the user the option to show it, we want to show the
+ # expired time, and so we add -a here.
+ if (defined($opts{'expired'})) { push @{$show_args}, '-a'; }
+ cli_show($self, { args => $show_args });
+ }
+ } elsif (scalar(@matches) > 1) {
+ print "Would you like to list them now? [y/N] ";
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) eq 'y') {
+ cli_ls($self,{"args" => ["/$FOUND_DIR/"]});
+ }
+ }
+}
+
+# Something is going wrong between KeePassX and File::KeePass related to
+# the unknown values read/written by File::KeePass from/to files written
+# by KeePassX. Commenting out line 378 of File/KeePass.pm is one fix,
+# this prevents me from needing to do that by just removing the unknown
+# values before saving. If there is a downside to this on the KeePassX
+# side I've not found it yet. I do have an email out to Paul, the author
+# of File::KeePass, requesting some assistance in grokking the problem.
+#
+# NOTE: I thought that this should not be needed for File::Keepass >= 0.3,
+# but on 2011-02-02 I discovered that creating new groups with
+# File::Keepass and not scrubbing on save created corrupt files:
+# "Group header offset is out of range" errors.
+# Sourceforge bug# 3187054 demonstrated the problem as well.
+sub scrub_unknown_values_from_all_groups {
+ our $state;
+ # No need to do this with newer versions of File::KeePass (fixed in 2.01)
+ if (version->parse($File::KeePass::VERSION) >= version->parse('2.03')) {
+ return;
+ }
+ my $k=$state->{kdb};
+ my @all_groups_flattened = $k->find_groups({});
+ my @unkown_field_groups=();
+ foreach my $g (@all_groups_flattened) {
+ if (defined($g->{unknown})) {
+ #warn "LHHD: " . &Dumper($g->{unknown}) . "\n";
+ delete $g->{unknown};
+ push @unkown_field_groups, $g->{title};
+ }
+ }
+ my $count = scalar(@unkown_field_groups);
+ if ($count > 0) {
+ warn "Deleted unknown fields from these $count groups: " .
+ join(", ", @unkown_field_groups) . "\n";
+ }
+}
+
+sub deny_if_readonly {
+ our %opts;
+ if (defined($opts->{readonly}) && int($opts->{readonly})) {
+ print "Function not available with --readonly set.\n";
+ return 1;
+ }
+ return 0;
+}
+
+sub cli_save($) {
+ my $self = shift @_;
+ my $params = shift @_;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+ if (deny_if_readonly()) { return; }
+
+ our $state;
+ if (! length($state->{kdb_file})) {
+ print "Please use the saveas command for new files.\n";
+ return;
+ }
+
+ # If the user has asked for a *.kdbx file, check the File::KeePass version
+ if (version->parse($File::KeePass::VERSION) < version->parse('2.03')) {
+ if ($state->{kdb_file} =~ m/\.kdbx$/i) {
+ print "KeePass v2 (*.kdbx files) require File::KeePass >= v2.03\n";
+ return;
+ }
+ }
+
+ if (warn_if_file_changed()) {
+ return;
+ }
+
+ # Check for a lock file that we did not place there
+ my $lock_file = $state->{kdb_file} . '.lock'; # KeePassX style
+ if (-f $lock_file && $state->{placed_lock_file} ne $lock_file) {
+ print color('bold yellow') . "WARNING:" . color('clear') .
+ color('red') .
+ " A KeePassX-style lock file is in place for this file.\n" .
+ " It may be opened elsewhere. Save anyway? [y/N] " .
+ color('clear');
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) ne 'y') {
+ return;
+ }
+ }
+
+ # If we got this far the user is OK with us locking the file even
+ # if we had a contention.
+ touch_file($lock_file);
+ $state->{placed_lock_file} = $lock_file;
+
+ # Scrub the data and write the file
+ destroy_found();
+ scrub_unknown_values_from_all_groups(); # TODO - remove later
+ my $k=$state->{kdb};
+ $k->unlock;
+ my $master_pass=
+ composite_master_pass($state->{get_master_passwd}(),$state->{key_file});
+ $k->save_db($state->{kdb_file},$master_pass);
+ $state->{kdb_has_changed}=0; # set our state to no change since last save
+ $master_pass="\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
+ print "Saved to $state->{kdb_file}\n";
+ $k->lock;
+ my $file = $state->{kdb_file};
+
+ # We hold a read file handle open for no reason other than
+ # to show up in lsof.
+ if (defined($state->{kdb_file_handle})) {
+ close $state->{kdb_file_handle};
+ }
+ $state->{kdb_file_handle} = new FileHandle;
+ open($state->{kdb_file_handle}, '<', $file);
+
+ # Update the md5sum of the file after we just saved it
+ $state->{kdb_file_md5} = Digest::file::digest_file_hex($file, "MD5");
+}
+
+# This subroutine handles the clipboard commands (xw, xu, xp, and xx)
+sub cli_xN($$) {
+ my $xNcmd = shift @_;
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ # If Clipboard is not avaiable we can't do this for the user
+ if (! $state->{OPTIONAL_PM}->{'Clipboard'}->{loaded}) {
+ print "Error: $xNcmd requires the Clipboard and Capture::Tiny modules:\n" .
+ " - http://search.cpan.org/~king/Clipboard/\n" .
+ " - http://search.cpan.org/~dagolden/Capture-Tiny/\n" .
+ "";
+ if (defined($state->{OPTIONAL_PM}->{'Clipboard'}->{error})) {
+ print "\nThere was an error loading the Clipboard module, as follows:\n" .
+ $state->{OPTIONAL_PM}->{'Clipboard'}->{error} . "\n";
+ }
+ return;
+ }
+
+ # If we're clearing the clipboard, just do that and return immediately.
+ if ($xNcmd eq 'xx') {
+ Clipboard->copy('');
+ print "Clipboard cleared.\n";
+ return;
+ }
+
+ # Find the entry that the user wants to copy to the clipboard from.
+ my $target = $params->{args}->[0];
+ my $ent=find_target_entity_by_number_or_path($target);
+ if (! defined($ent)) {
+ print "Don't see an entry at path: $target\n";
+ return -1;
+ }
+
+ # Switch over the xN commands and place the data into $to_copy
+ my $to_copy = '';
+ SWITCH: {
+ $xNcmd eq 'xu' && do { $to_copy = $ent->{username}; last SWITCH; };
+ $xNcmd eq 'xw' && do { $to_copy = $ent->{url}; last SWITCH; };
+ $xNcmd eq 'xp' && do {
+ $to_copy = $state->{kdb}->locked_entry_password($ent);
+ last SWITCH; };
+ warn "Error: cli_xN() does not know how to handle the $xNcmd command.";
+ $to_copy = undef;
+ }
+
+ # Copy to the clipboard and tell the user what we did.
+ my $cp_map = {
+ 'xu' => 'username',
+ 'xw' => 'url',
+ 'xp' => 'password',
+ };
+ if (defined($to_copy)) {
+ Clipboard->copy($to_copy);
+ print "Copied $cp_map->{$xNcmd} for \"$ent->{title}\" to the clipboard.\n";
+ }
+
+ return;
+}
+
+sub cli_rm($) {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
+ return;
+ }
+
+ my $target = $params->{args}->[0];
+ my $ent=find_target_entity_by_number_or_path($target);
+ if (! defined($ent)) {
+ print "Don't see an entry at path: $target\n";
+ return -1;
+ }
+
+ # Recycle the entry unless --no-recycle
+ if (! (defined($opts->{'no-recycle'}) && int($opts->{'no-recycle'}))) {
+ my $errmsg = recycle_entry($state, $ent);
+ if (defined($errmsg)) { print "WARNING: $errmsg\n"; }
+ }
+
+ $state->{kdb}->delete_entry({ id => $ent->{id} });
+ $state->{kdb_has_changed}=1;
+ refresh_state_all_paths();
+ RequestSaveOnDBChange();
+}
+
+sub validate_entry_number($$) {
+ my $item_number=shift @_;
+ my $path=shift @_;
+ our $state;
+
+ if (normalize_path_string($path) eq '') {
+ print "Entries cannot exist in the root path (/).\n";
+ return -1;
+ }
+
+ if ($item_number !~ m/^[0-9]+$/) {
+ print "Invalid item number (must be an integer).\n";
+ return -1;
+ }
+
+ my ($rGrps,$rEnts) = get_groups_and_entries($path);
+ my $entry_max=scalar(@{$rEnts}) - 1;
+ if ($entry_max < 0) {
+ print "Invalid item number. No valid entries in $path.\n";
+ return -1;
+ } elsif ($item_number > $entry_max) {
+ print "Invalid item number. Valid entries in \"$path\" are 0-$entry_max.\n";
+ return -1;
+ }
+return 0;
+}
+
+# This routine takes one parameter that will be either a path
+# to an entity or an entity number as shown by the ls command
+# and will use $state information such as last_ls_ents to
+# return a reference to that entity in the $state-{kdb} database,
+# if possible (valid input).
+sub find_target_entity_by_number_or_path($) {
+ my $target=shift @_;
+ our $state;
+
+ my $ent=undef; # hope to populate this in a second...
+
+ # This section looks for an entity by an "ls" number
+ if ($target =~ m/^[0-9]+$/ && scalar(@{$state->{last_ls_ents}}) > 0
+ && $target < scalar(@{$state->{last_ls_ents}}) ) {
+ return @{$state->{last_ls_ents}}[$target];
+ }
+
+ # This section looks by a path name
+ if (defined $state->{all_ent_paths_fwd}->{normalize_path_string($target)}) {
+ my $entry_id=$state->{all_ent_paths_fwd}->{normalize_path_string($target)};
+ $ent = $state->{kdb}->find_entry( {id=>$entry_id} );
+ }
+
+ # If we found the entry, place the path to this entry in the entry record,
+ # even if it's already there (it may have just changed via cli_mv), if the
+ # path we have for this entity is not the $FOUND_DIR.
+ if (defined($ent)) {
+ my $full_path = '/' .
+ humanize_path($state->{all_ent_paths_rev}->{$ent->{id}});
+ my $path = dirname($full_path) . '/';
+ if ("/$FOUND_DIR/" ne $path) {
+ $ent->{full_path} = $full_path;
+ $ent->{path} = $path;
+ }
+ }
+
+ return $ent;
+}
+
+sub cli_rename($$) {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
+ return;
+ }
+
+ my $target_dir = $params->{args}->[0];
+ my $dir_normalized=normalize_path_string($target_dir);
+ my $grp=undef;
+ if (defined($state->{all_grp_paths_fwd}->{$dir_normalized})) {
+ my $grp_id = $state->{all_grp_paths_fwd}->{$dir_normalized};
+ $grp=$state->{kdb}->find_group( { id => $grp_id } );
+ }
+ if (! defined($grp)) {
+ print "Unknown group: $target_dir\n";
+ return -1;
+ }
+
+ my $prompt = color('clear')."Enter the groups new Title: ";
+ my $term = get_prepped_readline_term();
+ my $new_title = $term->readline($prompt);
+ # If the user hit ^C (SIGINT) then we need to stop
+ if (recent_sigint()) { return undef; }
+ chomp($new_title);
+ if (length($new_title)) {
+ if ($new_title =~ m/\//) {
+ print "kpcli cannot support titles with slashes (/) in them.\n";
+ return undef;
+ }
+ # If the titles are the same then there is nothing to do
+ if ($new_title eq $grp->{title}) { return 0; }
+ # Check the new title for same-name conflicts in its group
+ my $path = dirname('/'.humanize_path($dir_normalized));
+ my $testdir=normalize_path_string("$path/$new_title");
+ if (defined($state->{all_grp_paths_fwd}->{$testdir})) {
+ print "An entry titled \"$new_title\" is already in $path.\n";
+ return undef;
+ }
+ # If we passed all of our sanity checks then set the new title
+ $grp->{title} = $new_title;
+ } else {
+ return 0;
+ }
+
+ # Because we renamed a group we must refresh our $state paths
+ refresh_state_all_paths();
+ $state->{kdb_has_changed}=1;
+ RequestSaveOnDBChange();
+}
+
+sub cli_mv {
+ my $self = shift @_;
+ my $params = shift @_;
+ my $src_path = shift @_;
+ my $target_dir = shift @_;
+ my $skip_save = shift @_ || 0;
+ our $state;
+
+ if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
+ return;
+ }
+
+ # The target has to be a group. We start validation there (the target).
+ my $dir_normalized=normalize_path_string($target_dir);
+ my $grp=undef;
+ if (defined($state->{all_grp_paths_fwd}->{$dir_normalized})) {
+ my $grp_id = $state->{all_grp_paths_fwd}->{$dir_normalized};
+ $grp=$state->{kdb}->find_group( { id => $grp_id } );
+ }
+ if (! defined($grp)) {
+ print "Unknown group: $target_dir\n";
+ return -1;
+ }
+
+ # The source (thing we are moving) can be an entity, group, or a
+ # shell_expansion. Here we figure out which one and prepare to
+ # exectute the move(s) below.
+ my $ent=undef;
+ my $mv_type = undef;
+ if ($ent=find_target_entity_by_number_or_path($src_path)) {
+ $mv_type = 'entry';
+ } elsif (defined($state->{all_grp_paths_fwd}->{normalize_path_string($src_path)})) {
+ $mv_type = 'group';
+ } else {
+ # For shell_expansion moves, we call cli_mv for each shell_expansion item,
+ # with skip_save set to true.
+ my @ent_matches = shell_expansion($src_path);
+ if (scalar(@ent_matches) > 0) {
+ $mv_type = 'shell_expansion';
+ foreach my $mv_src (@ent_matches) {
+ my $skip_save = 1;
+ cli_mv($self,$params,'/'.humanize_path($mv_src),$target_dir,$skip_save);
+ }
+ }
+ }
+ if (! defined($mv_type)) {
+ print "Unknown entity: " . humanize_path($src_path) . "\n";
+ return -1;
+ }
+
+ # Execute the move of the entry or group.
+ if ($mv_type eq 'entry') {
+ # Verify no entry title conflict at the new location
+ my $new_entry_path=normalize_path_string($target_dir . "/" . $ent->{title});
+ if (defined($state->{all_ent_paths_fwd}->{$new_entry_path}) ||
+ defined($state->{all_grp_paths_fwd}->{$new_entry_path})) {
+ my $path = dirname(humanize_path($new_entry_path));
+ print "ERROR: already an item named \"$ent->{title}\" at $path/.\n";
+ return undef;
+ }
+
+ # Unlock the kdb, clone the entry, remove its ID and set its new group,
+ # add it to the kdb, delete the old entry, then lock the kdb...
+ $state->{kdb}->unlock;
+ my %ent_copy = %{clone($ent)}; # Clone the entity
+ delete $ent_copy{id};
+ $ent_copy{group} = $grp;
+ if ($state->{kdb}->add_entry(\%ent_copy)) {
+ $state->{kdb}->delete_entry({ id=>$ent->{id} });
+ }
+ $state->{kdb}->lock;
+ print "Moved \"$ent->{title}\" to ".dirname(humanize_path($new_entry_path))."/\n";
+ } elsif ($mv_type eq 'group') {
+ # Find the group that the user is asking us to move
+ my $src_grp=$state->{kdb}->find_group(
+ {id => $state->{all_grp_paths_fwd}->{normalize_path_string($src_path)}});
+ my $new_group_path=normalize_path_string($target_dir . "/" . $src_grp->{title});
+ if (defined($state->{all_grp_paths_fwd}->{$new_group_path}) ||
+ defined($state->{all_ent_paths_fwd}->{$new_group_path})) {
+ my $path = dirname(humanize_path($new_group_path));
+ print "ERROR: already an item named \"$src_grp->{title}\" at $path/.\n";
+ return undef;
+ }
+
+ # Clone the group that is to be moved
+ my %new_group = %{$src_grp};
+ # Delete the id and level from the cloned group
+ delete $new_group{id};
+ delete $new_group{level};
+ # Add the group ID of the parent we want to put our clone under
+ $new_group{group} = $grp->{id}; # Set the new parent group
+ # Add the clone as a new group
+ $state->{kdb}->add_group(\%new_group);
+ # Delete the original group that we just cloned into a new spot
+ $state->{kdb}->delete_group({ id => $src_grp->{id} });
+ print "Moved \"$src_grp->{title}/\" to ".dirname(humanize_path($new_group_path))."/\n";
+ } elsif ($mv_type ne 'shell_expansion') {
+ print "Unknown error with move command.\n";
+ return -1;
+ }
+
+ # Because we moved an entry we must refresh our $state paths
+ if (! $skip_save) {
+ refresh_state_all_paths();
+ $state->{kdb_has_changed}=1;
+ RequestSaveOnDBChange();
+ }
+}
+
+sub cli_copy {
+ my $self = shift @_;
+ my $params = shift @_;
+ my $src = shift @_;
+ my $dst = shift @_;
+ my $skip_save = shift @_ || 0;
+ our $state;
+
+ if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
+ return;
+ }
+
+ my $source_ent = $src;
+ my $src_ent=find_target_entity_by_number_or_path($source_ent);
+ if (! defined($src_ent)) {
+ print "Unknown entry: $source_ent\n";
+ return -1;
+ }
+
+ my $target_ent = $dst;
+ my $trg_ent=find_target_entity_by_number_or_path($target_ent);
+ if (defined($trg_ent)) {
+ print "Copy cannot overwrite an existing entry.\n";
+ return -1;
+ }
+
+ # Unlock the kdb, clone the entry, remove its ID and set its new group,
+ # add it to the kdb, delete the old entry, then lock the kdb...
+ $state->{kdb}->unlock;
+ my %ent_copy = %{clone($src_ent)}; # Clone the entity
+ $state->{kdb}->lock;
+ delete $ent_copy{id}; # Remove the entry ID for the new copy
+ my ($grp_path,$name)=normalize_and_split_raw_path($target_ent);
+ $ent_copy{title} = humanize_path($name);
+ # group needs to be set to the ID of the group we may be moving the copy to
+ my $new_grp_id = $state->{all_grp_paths_fwd}->{$grp_path};
+ if (! defined($new_grp_id)) {
+ print "Copy failed due to missing target group ID.\n";
+ return -1;
+ } else {
+ $ent_copy{group} = $new_grp_id;
+ }
+ $state->{kdb}->unlock;
+ if (! $state->{kdb}->add_entry(\%ent_copy)) {
+ print "Copy failed on add_entry().\n";
+ $state->{kdb}->lock;
+ return -1;
+ }
+ $state->{kdb}->lock;
+
+ # Because we added an entry we must refresh our $state paths
+ if (! $skip_save) {
+ refresh_state_all_paths();
+ $state->{kdb_has_changed}=1;
+ RequestSaveOnDBChange();
+ }
+ return 0;
+}
+
+sub cli_clone($$) {
+ my $self = shift @_;
+ my $params = shift @_;
+ my $src = shift @_;
+ my $dst = shift @_;
+ our $state;
+
+ if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
+ return;
+ }
+
+ my $skip_save = 1;
+ my $retval_copy = cli_copy($self, $params, $src, $dst, $skip_save);
+ if ($retval_copy) {
+ return -1;
+ }
+ refresh_state_all_paths();
+
+ my $target_ent = $params->{args}->[1];
+ my $ent=find_target_entity_by_number_or_path($target_ent);
+ if (! (defined($ent) && exists($ent->{id}))) {
+ print "Copy failed.\n";
+ return -1;
+ }
+
+ $state->{kdb_has_changed}=1;
+ my %changes = ();
+ my $retval_edit = _entry_edit_gui($ent, \%changes);
+
+ # Apply the user changes, update modify time and prompt to save
+ if ($retval_edit == 0) {
+ $state->{kdb}->unlock; # Required for the password field
+ foreach my $key (keys %changes) { $ent->{$key} = $changes{$key}; }
+ $state->{kdb}->lock;
+ $ent->{modified} = $state->{kdb}->now;
+ refresh_state_all_paths();
+ $state->{kdb_has_changed}=1;
+ RequestSaveOnDBChange();
+ return 0;
+ } else {
+ # If the edit failed (or if the user hit ^C while in edit_gui), then
+ # remove the target entity that cli_copy() made.
+ my $ent_id=$ent->{id};
+ $state->{kdb}->delete_entry({ id => $ent_id });
+ refresh_state_all_paths();
+ $state->{kdb_has_changed}=0; # Should be back to how we started
+ return $retval_edit;
+ }
+}
+
+sub cli_show($$) {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ # Users can provide a -f option to show the password. We use GetOptions
+ # to parse this command line, and $target holds that target.
+ my $target='';
+ my %opts=();
+ {
+ local @ARGV = @{$params->{args}};
+ my $result = &GetOptions(\%opts, 'f', 'a');
+ if (scalar(@ARGV) != 1) {
+ return -1;
+ }
+ $target = $ARGV[0];
+ }
+
+ my $ent=find_target_entity_by_number_or_path($target);
+ if (! defined($ent)) {
+ return -1;
+ }
+
+ print "\n";
+ if (defined($ent->{path})) {
+ print show_format("Path",$ent->{path}) . "\n";
+ }
+ # Unless -f is specified, we "hide" the password as red-on-red.
+ my $password = $state->{kdb}->locked_entry_password($ent);
+ if (! defined($opts{f})) {
+ $password = colored(['red on_red'], $password);
+ }
+ # Print the entry for the user
+ print
+ show_format("Title",$ent->{title}) . "\n" .
+ show_format("Uname",$ent->{username}) . "\n" .
+ show_format("Pass",$password) . "\n" .
+ show_format("URL",$ent->{url}) . "\n" .
+ show_format("Notes",$ent->{comment}) . "\n" .
+ ($DEBUG ? show_format("ID",$ent->{id}) . "\n" : '');
+ # Tags were added to KeePass in v2.11 (*.kdbx)
+ if (defined($ent->{tags}) && length($ent->{tags})) {
+ print show_format("Tags", humanize_entry_tags($ent->{tags})) . "\n";
+ }
+ # Check for strings and file attachments in this entry
+ foreach my $key (qw(strings binary)) {
+ my $attachment = show_helper_files_strings($ent,{f=>1},$key);
+ #my $attachment = show_helper_files_strings($ent, \%opts, $key);
+ if (length($attachment)) { print $attachment; }
+ }
+ # If -a was given, tack on those details
+ if (defined($opts{a})) {
+ print
+ show_format("Icon#",$ent->{icon}) . "\n" .
+ show_format("Creat",$ent->{created}) . "\n" .
+ show_format("Modif",$ent->{modified}) . "\n";
+ if (defined($ent->{expires})) {
+ my $expires = $ent->{expires};
+ if (does_expire_never($state->{kdb_ver}, $ent)) {
+ $expires = 'Never';
+ }
+ print show_format("Xpire",$expires) . "\n";
+ }
+ }
+ print "\n";
+ print &Dumper($ent) . "\n" if ($DEBUG > 2);
+}
+
+# A helper function to display key/val pairs and file attachments.
+# Note that strings (key/val pairs) and multiple attachments per
+# entry only exist in KeePass 2.x (kdbx) files, while verions 1.x
+# files (kdb) support only a single file attachment per entry.
+sub show_helper_files_strings {
+ my $ent = shift @_;
+ my $opts = shift @_;
+ my $key = shift @_;
+ my $labels = {
+ 'binary' => ['Atchm','File Attachments'],
+ 'strings' => ['Strgs','String Values'],
+ };
+ my @atts=();
+ if (defined($ent->{$key})) {
+ foreach my $name (sort keys %{$ent->{$key}}) {
+ if ($key eq 'binary') {
+ push @atts, "$name (". int(length($ent->{$key}->{$name})) ." bytes)";
+ } elsif ($key eq 'strings') {
+ if (defined($opts->{f})) {
+ push @atts, "$name = " . $ent->{$key}->{$name};
+ } else {
+ push @atts, "$name = ".colored(['red on_red'],$ent->{$key}->{$name});
+ }
+ }
+ }
+ }
+ my $attachment = '';
+ my $label = $labels->{$key}[0];
+ if (scalar(@atts) == 0 && defined($opts->{v})) {
+ $attachment = "None";
+ } elsif (scalar(@atts) == 1) {
+ $attachment = $atts[0];
+ } elsif (scalar(@atts) > 1) {
+ $label = $labels->{$key}[1];
+ my @t=();
+ foreach my $num (1..scalar(@atts)) {
+ push(@t, sprintf("%2d) %s", $num, $atts[$num-1]));
+ }
+ $attachment = "\n " . join("\n ", @t) . "\n";
+ }
+ return ($attachment ne '' ? show_format($label,$attachment) . "\n" : '');
+}
+
+sub cli_edit {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
+ return;
+ }
+
+ my $target = $params->{args}->[0];
+ my $ent=find_target_entity_by_number_or_path($target);
+ if (! defined($ent)) {
+ print "Don't see an entry at path: $target\n";
+ return -1;
+ }
+ # Protect users from editing in the $FOUND_DIR.
+ my $ent_path = $state->{all_ent_paths_rev}->{$ent->{id}};
+ if ($ent_path =~ m/^\Q$FOUND_DIR\E/) {
+ print color('yellow')
+ . "That entity is in the temporary /$FOUND_DIR dir.\n"
+ . color('clear');
+ my $real_path = $ent->{full_path};
+ my $real_ent = find_target_entity_by_number_or_path($real_path);
+ if (defined($real_ent)) {
+ print "Would you rather edit $real_path? [y/N]";
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) eq 'y') {
+ $ent = $real_ent;
+ }
+ }
+ }
+
+ my %changes = ();
+ my $retval = _entry_edit_gui($ent, \%changes, $state->{kdb_ver});
+
+ # If the user made changes, apply them, update modify time and prompt to save
+ if ($retval == 0 && scalar(keys(%changes)) > 0) {
+ # Recycle the entry unless --no-recycle
+ if (! (defined($opts->{'no-recycle'}) && int($opts->{'no-recycle'}))) {
+ my $errmsg = recycle_entry($state, $ent);
+ if (defined($errmsg)) { print "WARNING: $errmsg\n"; }
+ }
+ # Update the entry's changed fields
+ $state->{kdb}->unlock; # Required for the password field
+ foreach my $key (keys %changes) { $ent->{$key} = $changes{$key}; }
+ $state->{kdb}->lock;
+ $ent->{modified} = $state->{kdb}->now;
+ refresh_state_all_paths();
+ $state->{kdb_has_changed}=1;
+ RequestSaveOnDBChange();
+ }
+
+return 0;
+}
+
+# A routine to copy entries to the /Backup or "/Recycle Bin" folders.
+# Used by cli_edit, cli_rm, etc.
+sub recycle_entry {
+ my $state = shift @_;
+ my $ent = shift @_;
+ my $bugrp_id = get_recycle_group_path($state, 1);
+ if (defined($bugrp_id)) {
+ $state->{kdb}->unlock; # Required for the password field
+ my $tmp_ent = clone($ent); # Clone the entity and back it up
+ delete $tmp_ent->{id};
+ # Append the modified time to the title for the backup dir
+ $tmp_ent->{title} = $tmp_ent->{title} . " (". $tmp_ent->{modified} .")";
+ $tmp_ent->{group} = $bugrp_id;
+ $tmp_ent->{modified} = $state->{kdb}->now;
+ my $add_result = $state->{kdb}->add_entry($tmp_ent);
+ $state->{kdb}->lock;
+ if (! $add_result) {
+ return "Failed to copy the entry to the backup folder.";
+ } else {
+ $state->{kdb_has_changed}=1;
+ }
+ refresh_state_all_paths();
+ }
+ return undef;
+}
+
+# Returns /Backup or "/Recycle Bin" based on KeePass v1 or v2 file.
+# Creates the group if it is missing and $create_if_missing in true.
+sub get_recycle_group_path {
+ my $state = shift @_;
+ my $create_if_missing = shift @_ || 0;
+ my $bugrp = "/Backup"; # Default to v1 (is this correct?)
+ if ($state->{kdb_ver} == 2) {
+ $bugrp = "/Recycle Bin";
+ }
+ my $grp_path = normalize_path_string($bugrp);
+ if ($create_if_missing) {
+ if (! defined($state->{all_grp_paths_fwd}->{$grp_path})) {
+ my $group = $state->{kdb}->add_group({
+ title => $grp_path,
+ icon => $DEfAULT_BAKUP_ICON,
+ }); # root level group
+ refresh_state_all_paths();
+ $state->{kdb_has_changed}=1;
+ }
+ }
+ if (! defined($state->{all_grp_paths_fwd}->{$grp_path})) {
+ return undef;
+ }
+ return $state->{all_grp_paths_fwd}->{$grp_path};
+}
+
+# Helper function for cli_edit() and cli_clone()
+sub _entry_edit_gui($$$) {
+ my $ent=shift @_;
+ my $rChanges = shift @_;
+ my $kdb_ver = shift @_;
+ # Loop through the fields taking edits the user wants to make
+ my @fields = get_entry_fields($kdb_ver);
+ foreach my $input (@fields) {
+ my $current_val = $ent->{$input->{key}};
+ my $val = '';
+ if (defined($input->{user_prep_func})) {
+ $current_val = $input->{user_prep_func}($current_val);
+ }
+ if (defined($input->{user_edit_func})) {
+ $val = $input->{user_edit_func}($ent, $input, $current_val);
+ } elsif ($input->{multiline}) {
+ $val = new_edit_multiline_input($input, $current_val);
+ } else {
+ $val = new_edit_single_line_input($input, $current_val);
+ }
+ # If the user hit ^C (SIGINT) then we need to stop
+ if (recent_sigint()) { return -1; }
+ # Call the validate_func if it's defined
+ if (defined($input->{validate_func}) && length($val)) {
+ # Note that $val can be modified by the validate_func
+ if ($input->{validate_func}(\$val) != 0) {
+ print "Invalid $input->{txt} input.\n";
+ return -1;
+ }
+ }
+ # Check a new title for same-name conflicts in its group
+ if ($input->{key} eq 'title' && length($val)) {
+ if ($val =~ m/\//) {
+ print "kpcli cannot support titles with slashes (/) in them.\n";
+ return -1;
+ }
+ my $path = $ent->{path}; # The group's path of the entry we are editing
+ my $new_entry = normalize_path_string("$path/$val");
+ if (defined($state->{all_ent_paths_fwd}->{$new_entry})) {
+ print "An entry titled \"$val\" is already in $path.\n";
+ return -1;
+ }
+ }
+ # If the field was not empty, we'll change it to the new $val
+ if (length($val)) { $rChanges->{$input->{key}} = $val; }
+ }
+ return 0;
+}
+
+
+# A code-consolidation function...
+sub get_prepped_readline_term() {
+ our $state;
+
+ # Note: A 2nd Term::ReadLine::Gnu->new() just returns the same
+ # object as the one that Term::ShellUI is already using. With
+ # Term::Readline::Perl, an attempt to call new a 2nd time results
+ # in having a Term::Readline::Stub returned and a warning printed to
+ # STDERR, which we also don't want, and so I coded this the way that
+ # I did to make clear what is being done, and this comment for why.
+ my $term = $state->{term}->{term}; # Term::ShellUI's readline
+ $state->{active_readline} = $term;
+
+ # Use Term::ReadLine::Gnu more advanced functionality...
+ if ($term->ReadLine() eq 'Term::ReadLine::Gnu') {
+ # Called roughly 10-times per second while Gnu readline waits for input
+ $term->Attribs->{event_hook} = sub {
+ if (recent_sigint()) {
+ $term->Attribs->{event_hook} = undef;
+ $term->set_prompt('');
+ $term->Attribs->{line_buffer}='';
+ $term->Attribs->{done} = 1;
+ $state->{active_readline} = undef;
+ }
+ };
+ }
+
+ return $term;
+}
+
+# A helper function to interactively edit an entry's strings
+sub edit_entry_strings {
+ my $ent = shift @_;
+ my $input = shift @_;
+ my $initial_value = shift @_;
+ our $state;
+
+ my $tmp_ent = clone($ent); # Clone the entity
+ EDIT_INTERFACE: while (1) {
+ my @strings_keys = ();
+ if (defined($tmp_ent->{strings}) && ref($tmp_ent->{strings}) eq 'HASH') {
+ @strings_keys = sort keys %{$tmp_ent->{strings}};
+ }
+ my $strings_count = scalar(@strings_keys);
+ my $t='';
+ my $prompt = "Strings";
+ if ($strings_count > 0) {
+ $t .= show_helper_files_strings($tmp_ent,{f=>1,v=>1},'strings');
+ $prompt = "Choose";
+ }
+ $t .= "$prompt: (a)dd/(e)dit/(d)elete/(c)ancel/(F)inish? ";
+ print "$t";
+ COMMAND: while (my $key=get_single_key()) {
+ if (lc($key) eq 'c' || ord($key) == 3) { # Cancel or ^C
+ print "\n";
+ return ''; # We are to return nothing on no change
+ } elsif ($key =~ m/^[fF\r\n]$/) { # Finished (save)
+ print "\n";
+ return $tmp_ent->{strings};
+ } elsif (lc($key) eq 'd') {
+ if (defined($tmp_ent->{strings}) &&
+ ref($tmp_ent->{strings}) eq 'HASH') {
+ if ($strings_count < 2) {
+ %{$tmp_ent->{strings}} = ();
+ } else {
+ print "\r". " "x60 ."\rWhich entry number do you want to delete? ";
+ my $choice = get_single_line();
+ if ($choice !~ m/^\d+$/ || $choice<1 || $choice > $strings_count) {
+ print "\nInvalid number.";
+ } else {
+ delete($tmp_ent->{strings}->{$strings_keys[$choice-1]});
+ }
+ }
+ print "\n";
+ next EDIT_INTERFACE;
+ }
+ } elsif (lc($key) =~ m/^[ae]$/) {
+ my $iv='';
+ my $fld_being_edited = undef;
+ if (lc($key) eq 'e') {
+ print "\r". " "x60 ."\rWhich entry number do you want to edit? ";
+ my $choice = get_single_line();
+ if ($choice !~ m/^\d+$/ || $choice<1 || $choice > $strings_count) {
+ print "\nInvalid number.";
+ print "\n";
+ next EDIT_INTERFACE;
+ }
+ $fld_being_edited = $strings_keys[$choice-1];
+ my $val_being_edited = $tmp_ent->{strings}->{$fld_being_edited};
+ $iv = "$fld_being_edited = $val_being_edited";
+ }
+ my $prompt = "Input a key=value pair: ";
+ print "\r". " "x60 ."\r";
+ my $term = get_prepped_readline_term();
+ my $keyval = $term->readline($prompt, $iv);
+ #my $keyval = get_single_line();
+ if ($keyval =~ m/^\s*([^=]+?)\s*=\s*(.+?)\s*$/) {
+ my ($fld, $val) = ($1, $2);
+ if (!(defined($tmp_ent->{strings})) &&
+ ref($tmp_ent->{strings}) eq 'HASH') {
+ $tmp_ent->{strings} = {};
+ }
+ if (defined($tmp_ent->{strings}->{$fld}) &&
+ (lc($key) eq 'a' || $fld ne $fld_being_edited)) {
+ print "\nString field \"$fld\" already exists in the entry.";
+ } else {
+ $tmp_ent->{strings}->{$fld}=$val;
+ if (defined($fld_being_edited) && $fld ne $fld_being_edited) {
+ delete($tmp_ent->{strings}->{$fld_being_edited});
+ }
+ }
+ } else {
+ print "\nInvalid entry.";
+ }
+ print "\n";
+ next EDIT_INTERFACE;
+ } else {
+ # Do nothing on invalid input
+ next COMMAND;
+ }
+ }
+ }
+
+ return $initial_value;
+}
+# Single line input helper function for cli_new and cli_edit.
+sub new_edit_single_line_input($$) {
+ my $input = shift @_;
+ my $initial_value = shift @_;
+ our $state;
+
+ my $iv = ''; if (! $input->{hide_entry}) { $iv = $initial_value; }
+ my $term = get_prepped_readline_term();
+ my $val = '';
+
+ PASSWD_COLLECTION: {
+ if ($input->{genpasswd}) {
+ print " "x25 .'("g" or "w" to auto-generate, "i" for interactive)'. "\r";
+ }
+ my $prompt=$input->{txt} . ': ';
+ if ($input->{hide_entry}) {
+ $val = GetPassword($prompt, '');
+ } else {
+ $val = $term->readline($prompt, $iv);
+ }
+ chomp $val;
+ if ($input->{genpasswd} && $val =~ m/^([wgi])(\d*)$/) {
+ my $cmd = $1;
+ my $len = $2;
+ if ($cmd eq 'g' || $cmd eq 'i') {
+ if (!$len) {
+ $len = $DEFAULT_PASSWD_LEN;
+ } elsif ($len !~ m/^\d+$/ ||
+ $len < $DEFAULT_PASSWD_MIN || $len > $DEFAULT_PASSWD_MAX) {
+ $len = $DEFAULT_PASSWD_LEN;
+ print color('yellow')
+ . "Password length out of bounds, reset to $len chars.\n"
+ . color('clear');
+ }
+ if ($cmd eq 'g') {
+ $val = generatePasswordGobbledygook($len);
+ } elsif ($cmd eq 'i') {
+ $val = generatePasswordInteractive($len);
+ # Interactive canceled by user
+ if (! defined($val)) {
+ print "\n";
+ redo PASSWD_COLLECTION;
+ }
+ }
+ } elsif ($cmd eq 'w') {
+ $val = generatePassword();
+ } else {
+ die "BUG: it should be impossible to get to this code!\n";
+ }
+ } elsif (length($val) && $input->{double_entry_verify}) {
+ my $prompt = "Retype to verify: ";
+ my $checkval = '';
+ if ($input->{hide_entry}) {
+ $checkval = GetPassword($prompt, '');
+ } else {
+ $checkval = $term->readline($prompt);
+ }
+ # If the user hit ^C (SIGINT) then we need to stop
+ if (recent_sigint()) { return undef; }
+ chomp $checkval;
+ if ($checkval ne $val) {
+ print "Entries mismatched. Please try again.\n";
+ redo PASSWD_COLLECTION;
+ }
+ }
+ }
+ # We are done with readline calls so let's cleanup.
+ if ($term->ReadLine() eq 'Term::ReadLine::Gnu') {
+ $term->Attribs->{startup_hook} = undef;
+ $term->Attribs->{event_hook} = undef;
+ }
+ # This funciton is supposed to return blank if nothing changed
+ if ($val eq $initial_value) { $val = ''; }
+ return $val;
+}
+# Multi-line input helper function for cli_new and cli_edit.
+sub new_edit_multiline_input($$) {
+ my $input = shift @_;
+ my $initial_value = shift @_;
+ our $state;
+
+ # Protect programmers from accidentally misusing this function
+ if ($input->{genpasswd}) { die "genpasswd unsupported by this function"; }
+
+ if ($input->{hide_entry}) {
+ print $input->{txt} . ": ";
+ } else {
+ my $mlval = $initial_value;
+ if ($mlval =~ m/\r|\n/) { $mlval = "\n$mlval\n"; }
+ print $input->{txt} . " (\"".$mlval."\"): ";
+ }
+
+ my $yellow=color('yellow');
+ my $clear=color('clear');
+ print "\n$yellow(end multi-line input with a single \".\" on a line)$clear\n";
+
+ my $term = get_prepped_readline_term();
+
+ my $val = ''; my $unfinished = 1;
+ while ($unfinished) {
+ # Term::ReadLine::Perl seems to have a bug that requires a prompt
+ # (it borks on ''), but after adding the pipe I liked it so left
+ # it place for all readlines.
+ my $line = $term->readline('| ');
+ if ($line =~ m/^\.[\r\n]*$/) { # a lone "." ends our input
+ $unfinished = 0;
+ } else {
+ $val .= $line;
+ $val .= "\n" if ($val !~ m/\n$/); # ReadLine() vs. $term->readline().
+ if ($val =~ m/^[\r\n]*$/) { $val = ''; $unfinished = 0; }
+ }
+ # If the user hit ^C (SIGINT) then we need to stop
+ if (recent_sigint()) { return undef; }
+ }
+ chomp($val); # Remove extra line at the end
+ return $val;
+}
+
+# Formats an entry for display for cli_show()
+sub show_format($$) {
+ my $title=shift @_;
+ my $value=shift @_;
+ my $val=$value;
+ if ($val =~ m/\n/) {
+ my @val_lines=split(/\n/, $val);
+ $val=join("\n" . " "x(5+2), @val_lines);
+ }
+ return sprintf("%5s: %s", $title,$val);
+}
+
+# Formats a statistic display for cli_stats()
+sub stats_print($) {
+ my $stats_r = shift @_;
+
+ my $max_key_len = 0;
+ my $max_val_len = 0;
+ foreach my $k (keys(%{$stats_r})) {
+ if (length($k) > $max_key_len) { $max_key_len = length($k); }
+ }
+ foreach my $k (values(%{$stats_r})) {
+ if (length($k) > $max_val_len) { $max_val_len = length($k); }
+ }
+ my $sprintf_format = " - %$max_key_len" . "s: %$max_val_len" . "d\n";
+ my $t='';
+ foreach my $stat_key (sort {$a <=> $b} keys %{$stats_r}) {
+ $t .= sprintf($sprintf_format, $stat_key, $stats_r->{$stat_key});
+ }
+ return $t;
+}
+
+# Helper functions to normalize and humanize entry tags
+sub normalize_entry_tags {
+ my $rTagline = shift @_;
+ my @tags = split(/\s*[;,]\s*/, $$rTagline);
+ $$rTagline = join(';', @tags);
+ return 0;
+}
+sub humanize_entry_tags {
+ my $tagline = shift @_;
+ my @tags = split(/\s*[;,]\s*/, $tagline);
+ return join(', ', @tags);
+}
+
+# Function to return a data structure to support adding/editing entries
+sub get_entry_fields {
+ my $kdb_ver = shift @_ || 1; # *.kdb or *.kdbx (v1 or v2)
+ my @fields = (
+ { key=>'title', txt=>'Title' },
+ { key=>'username', txt=>'Username' },
+ { key=>'password', txt=>'Password',
+ hide_entry => 1, double_entry_verify => 1, genpasswd => 1 },
+ { key=>'url', txt=>'URL' },
+ );
+ # We only want "tags" if we are dealing with a v2 file (*.kdbx)
+ if ($kdb_ver == 2) {
+ push @fields, (
+ { key=>'tags', txt=>'Tags', 'multiline' => 0,
+ user_prep_func => \&humanize_entry_tags,
+ validate_func => \&normalize_entry_tags,
+ },
+ { key=>'strings', txt=>'Strgs',
+ user_edit_func => \&edit_entry_strings,
+ },
+ );
+ }
+ push @fields, (
+ { key=>'comment', txt=>'Notes/Comments', 'multiline' => 1 },
+ );
+ return @fields;
+}
+
+# Code consolidation function.
+# Returns true if $state->{signals}->{INT} indicates a SIGINT more
+# recently than the $timeframe given, which defaults to 0.25 secs.
+sub recent_sigint() {
+ my $timeframe = shift @_;
+ if ($timeframe !~ m/^([0-9]*\.[0-9]+|[0-9]+)$/) { $timeframe = 0.25; }
+ our $state;
+ if (defined($state->{signals}->{INT}) &&
+ tv_interval($state->{signals}->{INT}) < $timeframe) {
+ return 1;
+ }
+ return 0;
+}
+
+sub cli_icons($) {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ print "Change icons on Groups or Entries (g/e/Cancel)? ";
+ my $groups_or_entries=lc(get_single_key());
+ print "\n";
+ if (recent_sigint() || $groups_or_entries !~ m/^[ge]$/) { return; }
+
+ print "Change icons Here, Below here, or Globally (h/b/g/Cancel)? ";
+ my $glob_or_rel=lc(get_single_key());
+ print "\n";
+ if (recent_sigint() || $glob_or_rel !~ m/^[hgb]$/) { return; }
+
+ print "What would you line the new icon to be (0-64/Cancel)? ";
+ my $term = get_prepped_readline_term();
+ my $val = $term->readline('');
+ # If the user hit ^C (SIGINT) then we need to stop
+ if (recent_sigint()) { return undef; }
+ chomp($val);
+ if ($val !~ m/^[0-9]+$/ || $val < 0 || $val > 64) {
+ print "Invalid icon number.\n";
+ return;
+ }
+
+ # This code fills @{$groups} or @{$entries} with the items that the
+ # user wants to change the icons on.
+ my ($groups,$entries) = ([],[]);
+ if ($glob_or_rel eq 'h') { # "Here" is easy -- we have a function for that.
+ ($groups,$entries) = get_current_groups_and_entries();
+ if ($groups_or_entries eq 'e') {
+ $groups = [];
+ } else {
+ $entries = [];
+ }
+ } else {
+ if ($glob_or_rel eq 'g') { # Globally is easy, it's all groups
+ my $k=$state->{kdb};
+ @{$groups} = $k->find_groups({});
+ } elsif ($glob_or_rel eq 'b') {
+ my $id=$state->{path}->{id};
+ @{$groups} = all_child_groups_flattened($id); # *only child groups*
+ } else {
+ warn "WHAT? Should never get to this piece of code!\n";
+ }
+ # If the user wanted to operate on entries, collect all the entries
+ # in the @{$groups} and then empty @{$groups}.
+ if ($groups_or_entries eq 'e') {
+ foreach my $group (@{$groups}) {
+ if (defined($group->{entries})) {
+ push @{$entries}, @{$group->{entries}};
+ }
+ }
+ $groups = [];
+ }
+ }
+
+ # Change the items, recording the number of changes.
+ my $items_changed=0;
+ foreach my $item (@{$groups}, @{$entries}) {
+ $item->{icon} = $val;
+ $items_changed++;
+ }
+
+ # Tell the user what we did.
+ print "The icon value was set to $val on $items_changed records.\n";
+
+ # If we changed anything, ask the user if they want to save
+ if ($items_changed > 0) {
+ $state->{kdb_has_changed}=1;
+ RequestSaveOnDBChange();
+ }
+ return 0;
+}
+
+sub cli_new($) {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
+ return;
+ }
+
+ my $new_path=get_pwd();
+ my $new_title='';
+ # If the user gave us a path (that may include the new title) in args[0],
+ # then we have to first rationalize that path.
+ if (defined($params->{args}->[0])) {
+ $new_path = $params->{args}->[0];
+ # Insure that $new_path is absolute
+ if ($new_path !~ m/^\/+$/) {
+ $new_path = get_pwd() . '/' . $new_path;
+ }
+ my $norm_path = normalize_path_string($new_path);
+ my ($grp_path,$name)=normalize_and_split_raw_path($new_path);
+ if (defined($state->{all_grp_paths_fwd}->{$norm_path})) {
+ $new_path = '/' . humanize_path($norm_path);
+ $new_title = '';
+ } elsif (defined($state->{all_grp_paths_fwd}->{$grp_path})) {
+ $new_path = '/' . humanize_path($grp_path);
+ $new_title = $name;
+ } else {
+ if ($norm_path eq '') {
+ print "Entries cannot be made in the root path.\n";
+ } else {
+ print "Bad path for new entry\n";
+ }
+ return;
+ }
+ }
+
+ # $id needs to be set to the ID of the group we want to add to
+ my $id = undef;
+ my $norm_path=normalize_path_string($new_path);
+ if (defined($state->{all_grp_paths_fwd}->{$norm_path})) {
+ $id=$state->{all_grp_paths_fwd}->{$norm_path};
+ }
+
+ # Make sure that we have a valid path for creating a new entry
+ if ($new_path =~ m/^\/+$/ || (! defined($id))) {
+ print "Entries cannot be made in this path ($new_path).\n";
+ return -1;
+ }
+
+ print "Adding new entry to \"$new_path\"\n";
+
+ # Grab the entries at this $id (pwd) so we can check for conflicts
+ my $k=$state->{kdb};
+ my ($this_grp, at trash) = $k->find_groups({id=>$id});
+ my @entries = $k->find_entries({group_id => $id});
+
+ my $new_entry = {
+ 'group' => $id,
+ };
+
+ my @fields = get_entry_fields($state->{kdb_ver});
+ NEW_DATA_COLLECTION: foreach my $input (@fields) {
+ my $val = '';
+ if (defined($input->{user_edit_func})) {
+ $val = $input->{user_edit_func}($new_entry, $input, {});
+ # An empty hash-ref is "empty/blank" for strings, but the
+ # user_edit_func returns the scalar '' in that case.
+ if ($input->{key} eq 'strings' && ref($val) ne 'HASH') { $val = {}; }
+ } elsif ($input->{multiline}) {
+ $val = new_edit_multiline_input($input, '');
+ } else {
+ if ($new_title ne '' && $input->{key} eq 'title') {
+ print $input->{txt} . ": $new_title\n";
+ $val = $new_title;
+ } else {
+ $val = new_edit_single_line_input($input, '');
+ }
+ }
+ # If the user hit ^C, abort the new entry
+ if (recent_sigint()) { return undef; }
+ # If the user gave us an empty title, abort the new entry
+ if ($input->{key} eq 'title' && length($val) == 0) { return undef; }
+ # Call the validate_func if it's defined
+ if (defined($input->{validate_func}) && length($val)) {
+ # Note that $val can be modified by the validate_func
+ if ($input->{validate_func}(\$val) != 0) {
+ print "Invalid $input->{txt} input.\n";
+ return -1;
+ }
+ }
+ # Check the new title for same-name conflicts in its group
+ if ($input->{key} eq 'title') {
+ if ($val =~ m/\//) {
+ print "kpcli cannot support titles with slashes (/) in them.\n";
+ return undef;
+ }
+ my $new_entry = normalize_path_string($new_path . '/' . $val);
+ if (defined($state->{all_ent_paths_fwd}->{$new_entry})) {
+ print "An entry titled \"$val\" is already in $new_path.\n";
+ return undef;
+ }
+ }
+
+ $new_entry->{$input->{key}} = $val;
+ }
+ $new_entry->{icon} = $DEFAULT_ENTRY_ICON;
+
+ $k->unlock;
+ my $new_entry_ref = $k->add_entry($new_entry);
+ $k->lock;
+ if ($new_entry_ref->{id}) {
+ refresh_state_all_paths();
+ $state->{kdb_has_changed}=1;
+ RequestSaveOnDBChange();
+ } else {
+ print "Failed to add new entry.\n";
+ }
+ return 0;
+}
+
+sub cli_import {
+ my $file=shift @_;
+ my $new_group_path=shift @_;
+ my $key_file=shift @_;
+ our $state;
+
+ if (recent_sigint() || deny_if_readonly()) { return; }
+
+ # If the user gave us a bogus file there's nothing to do
+ if (! -f ($file)) {
+ print "File does not exist: $file\n";
+ return -1;
+ }
+ if (magic_file_type($file) ne 'keepass') {
+ print "Does not appear to be a KeePass file: $file\n";
+ return -1;
+ }
+ # If the $new_group_path is a relative path, make it absolute
+ if ($new_group_path !~ m/^\//) {
+ $new_group_path = get_pwd() . "/$new_group_path";
+ }
+ # We won't import into an existing group
+ my $full_path=normalize_path_string($new_group_path);
+ if (defined($state->{all_grp_paths_fwd}->{$full_path})) {
+ print "You must specify a _new_ group to import into.\n";
+ return -1;
+ }
+ # Make sure the new group's parent exists
+ my ($grp_path,$grp_name)=normalize_and_split_raw_path($new_group_path);
+ if ($grp_path != '' && ! defined($state->{all_grp_paths_fwd}->{$grp_path})) {
+ print "Path does not exist: /" . humanize_path($grp_path) . "\n";
+ return -1;
+ }
+ # Set the $parent_group value appropriately
+ my $parent_group = undef; # Root by default
+ if (length($grp_path)) {
+ $parent_group = $state->{all_grp_paths_fwd}->{$grp_path};
+ }
+ # Ask the user for the master password and then open the kdb
+ my $master_pass=GetMasterPasswd();
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+ my $iKDB = File::KeePass->new;
+ if (! eval { $iKDB->load_db($file,
+ composite_master_pass($master_pass, $key_file)) }) {
+ print "Couldn't load the file $file: $@\n";
+ return -1;
+ }
+ # Add the new group, to its parent or to root if $parent_group==undef
+ my $k=$state->{kdb};
+ my $new_group=$k->add_group({
+ title => $grp_name,
+ group => $parent_group,
+ });
+ # Copy the $iKDB into our $k at $new_group
+ $iKDB->unlock();
+ $k->unlock();
+ my @root_groups = $iKDB->find_groups({level=>0});
+ foreach my $i_root_grp (@root_groups) {
+ copy_kdb_group_tree($k,$i_root_grp,$new_group);
+ }
+ $k->lock();
+ $iKDB->lock();
+ $iKDB=undef;
+ # Refresh all paths and mark state as changed
+ refresh_state_all_paths();
+ $state->{kdb_has_changed}=1;
+ RequestSaveOnDBChange();
+}
+
+sub cli_export($$) {
+ my $file=shift @_;
+ my $key_file=shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ # Warn is we are being asked to overwrite a file
+ if (-e $file) {
+ print "WARNING: $file already exists.\n" .
+ "Overwrite it? [y/N] ";
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) ne 'y') {
+ return -1;
+ }
+ }
+
+ # Ask if the key file should be generated, if it doesn't exist
+ my $make_keyfile=0;
+ if (defined($key_file) && (! -f $key_file)) {
+ print "Your specified key file does not exist. Generate it? [y/N] ";
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) ne 'y') {
+ return -1;
+ }
+ $make_keyfile=1;
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+ }
+
+ # Get the master password for the exported file
+ my $master_pass=GetMasterPasswdDoubleEntryVerify();
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+ if (! defined($master_pass)) { return undef; }
+ if (length($master_pass) == 0) {
+ print "For your safety, empty passwords are not allowed...\n";
+ return;
+ }
+
+ # Generate the key file if so instructed
+ if (defined($key_file) && $make_keyfile) {
+ my $fh=new FileHandle;
+ if (! open($fh,'>',$key_file)) {
+ print "ERROR: Could not open key file for writing: $key_file\n";
+ return -1;
+ }
+ my $KEYFILE_SIZE = 2048;
+ print $fh generateKeyfileContents($KEYFILE_SIZE);
+ close($fh);
+ my $size = (stat($key_file))[7];
+ if ($size != $KEYFILE_SIZE) {
+ print "ERROR: generated key file is the wrong size: $key_file\n";
+ return -1;
+ }
+ }
+
+ # Build the new kdb in RAM
+ my $k=$state->{kdb};
+ my $new_kdb=new File::KeePass;
+ $k->unlock; # Required so that we can copy the passwords
+ if (get_pwd() ne '/') {
+ # Grab the root group's $id at our pwd
+ my $pwd_group_id=$state->{path}->{id};
+ my ($root_grp, at trash) = $k->find_groups({id=>$pwd_group_id});
+ copy_kdb_group_tree($new_kdb,$root_grp,undef);
+ } else {
+ # Put all of the root groups into the new file (entire file copy)
+ my @root_groups = $k->find_groups({level=>0});
+ foreach my $root_grp (@root_groups) {
+ copy_kdb_group_tree($new_kdb,$root_grp,undef);
+ }
+ }
+ $k->lock;
+ $new_kdb->unlock;
+ my $new_db_bin =
+ $new_kdb->gen_db(composite_master_pass($master_pass,$key_file));
+ $new_kdb->lock;
+
+ # Test parsing the kdb from RAM (we'll most likely die if this fails)
+ my $new_db=new File::KeePass;
+ $new_db->parse_db($new_db_bin,composite_master_pass($master_pass,$key_file));
+
+ # Now write the new kdb to disk
+ my $fh=new FileHandle;
+ if (open($fh,'>',$file)) {
+ print $fh $new_db_bin;
+ close $fh;
+ print "Exported to $file\n";
+ } else {
+ print "Could not open \"$file\" for writing.\n";
+ }
+
+ return 0;
+}
+
+# A helper function for cli_export() and cli_import(). It takes a kdb object,
+# a group as a starting point to copy from, and optionally a parent_group to
+# copy to. It copies everything from the source group's root downward. In our
+# use cases, the _target_ $kdb object passed in here is typically a different
+# one than the _source_ $group is from.
+sub copy_kdb_group_tree {
+ my $kdb=shift @_;
+ my $group=shift @_;
+ my $parent_group=shift @_ || undef; # When undef, it writes to the root
+
+ # Add the new group, to it's parent or root if $parent_group==undef
+ my $new_group=$kdb->add_group({
+ title => $group->{title},
+ icon => $group->{icon},
+ id => $group->{id},
+ group => $parent_group,
+ });
+
+ # Add the new_group's entries
+ if (ref($group->{entries}) eq 'ARRAY') {
+ foreach my $entry (@{$group->{entries}}) {
+ $entry->{group} = $new_group;
+ $kdb->add_entry($entry);
+ }
+ }
+
+ # Add the new_group's child groups
+ if (ref($group->{groups}) eq 'ARRAY') {
+ foreach my $child_grp (@{$group->{groups}}) {
+ copy_kdb_group_tree($kdb,$child_grp,$new_group);
+ }
+ }
+}
+
+sub cli_saveas($) {
+ my $file=shift @_;
+ my $key_file=shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ # If the user has asked for a *.kdbx file, check the File::KeePass version
+ if (version->parse($File::KeePass::VERSION) < version->parse('2.03')) {
+ if ($file =~ m/\.kdbx$/i) {
+ print "KeePass v2 (*.kdbx files) require File::KeePass >= v2.03\n";
+ return;
+ }
+ }
+
+ # Warn is we are being asked to overwrite a file
+ if (-e $file) {
+ print "WARNING: $file already exists.\n" .
+ "Overwrite it? [y/N] ";
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) ne 'y') {
+ return -1;
+ }
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+ }
+
+ # Ask if the key file should be generated, if it doesn't exist
+ my $make_keyfile=0;
+ if (defined($key_file) && (! -f $key_file)) {
+ print "Your specified key file does not exist. Generate it? [y/N] ";
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) ne 'y') {
+ return -1;
+ }
+ $make_keyfile=1;
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+ }
+
+ # Get the master password for the file
+ my $master_pass=GetMasterPasswdDoubleEntryVerify();
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+ if (! defined($master_pass)) { return undef; }
+ if (length($master_pass) == 0) {
+ print "For your safety, empty passwords are not allowed...\n";
+ return;
+ }
+
+ # Generate the key file if so instructed
+ if (defined($key_file) && $make_keyfile) {
+ my $fh=new FileHandle;
+ if (! open($fh,'>',$key_file)) {
+ print "ERROR: Could not open key file for writing: $key_file\n";
+ return -1;
+ }
+ my $KEYFILE_SIZE = 2048;
+ print $fh generateKeyfileContents($KEYFILE_SIZE);
+ close($fh);
+ my $size = (stat($key_file))[7];
+ if ($size != $KEYFILE_SIZE) {
+ print "ERROR: generated key file is the wrong size: $key_file\n";
+ return -1;
+ }
+ }
+
+ destroy_found();
+ scrub_unknown_values_from_all_groups(); # TODO - remove later
+ $state->{kdb}->unlock;
+ $state->{kdb}->save_db($file,composite_master_pass($master_pass,$key_file));
+ $state->{kdb}->lock;
+
+ $state->{kdb}= File::KeePass->new;
+ if (! eval { $state->{kdb}->load_db($file,
+ composite_master_pass($master_pass,$key_file)) }) {
+ die "Couldn't load the file $file: $@";
+ }
+
+ # We hold a read file handle open for no reason other than
+ # to show up in lsof.
+ if (defined($state->{kdb_file_handle})) {
+ close $state->{kdb_file_handle};
+ }
+ $state->{kdb_file_handle} = new FileHandle;
+ open($state->{kdb_file_handle}, '<', $file);
+
+ $state->{kdb_file} = $file;
+ $state->{kdb_has_changed}=0;
+ $state->{kdb_file} = $file;
+ $state->{key_file} = $key_file;
+ $state->{put_master_passwd}($master_pass);
+ $master_pass="\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
+ return 0;
+}
+
+# This routine takes a raw (directly from user input) path string
+# and "normalizes" it (converts it to NULL-separated w/out escapes)
+# and splits it into its dirname and basename components.
+sub normalize_and_split_raw_path($) {
+ my $raw_pathstr=shift @_;
+
+ my $path=normalize_path_string($raw_pathstr);
+
+ my $basename='';
+ if ($path =~ m/\0/) { # case of at least one path delimeter
+ $path =~ s/\0([^\0]+)$//;
+ $basename=$1;
+ } elsif ($raw_pathstr =~ m/^\//) { # case of simple "/foo" (no subdir)
+ $basename=$path;
+ $path='';
+ } else { # case of simple "foo" (no path delims)
+ $path = normalize_path_string(get_pwd());
+ $basename=$raw_pathstr;
+ }
+return($path,$basename);
+}
+
+sub cli_rmdir($) {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
+ return;
+ }
+
+ my $raw_pathstr=$params->{'args'}->[0];
+ my ($path,$grp_name) = normalize_and_split_raw_path($raw_pathstr);
+
+ # Make sure the group exists.
+ my $grp_path="$path\0$grp_name";
+ $grp_path=~s/^\0+//;
+ if (! defined($state->{all_grp_paths_fwd}->{$grp_path})) {
+ print "Path does not exist: /" . humanize_path($grp_path) . "\n";
+ return -1;
+ }
+
+ my $group_id = $state->{all_grp_paths_fwd}->{$grp_path};
+ my $group = $state->{kdb}->find_group({ id => $group_id });
+ my @entries = ();
+ my $entry_cnt=0;
+ if (defined($group->{entries})) {
+ @entries = grep(m/^\Q$grp_path\E\0/,keys %{$state->{all_ent_paths_fwd}});
+ $entry_cnt = scalar(@entries);
+ }
+ my $group_cnt=0;
+ if (defined($group->{entries})) {
+ $group_cnt=
+ scalar(grep(m/^\Q$grp_path\E\0/,keys %{$state->{all_grp_paths_fwd}}));
+ }
+ my $child_cnt=$entry_cnt + $group_cnt;
+ if ( $child_cnt > 0) {
+ print "WARNING: This group has $child_cnt child groups and/or entries.\n" .
+ "Really remove it!? [y/N] ";
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) ne 'y') {
+ return -1;
+ }
+ }
+
+ # Recycle any entries that we're also removing
+ if ($entry_cnt > 0) {
+ if (! (defined($opts->{'no-recycle'}) && int($opts->{'no-recycle'}))) {
+ foreach my $ent_path (@entries) {
+ my $ent_id = $state->{all_ent_paths_fwd}->{$ent_path};
+ my $ent = $state->{kdb}->find_entry({id => $ent_id});
+ my $errmsg = recycle_entry($state, $ent);
+ if (defined($errmsg)) { print "WARNING: $errmsg\n"; }
+ }
+ }
+ }
+
+ # Delete the group, refresh our state paths, request save, etc.
+ my $deleted_group = $state->{kdb}->delete_group({ id => $group_id });
+ refresh_state_all_paths();
+ $state->{kdb_has_changed}=1;
+ RequestSaveOnDBChange();
+ return 0;
+}
+
+sub cli_mkdir($) {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
+ return;
+ }
+
+ my $raw_pathstr = $params->{args}->[0];
+ my ($path,$newdir) = normalize_and_split_raw_path($raw_pathstr);
+
+ # Make sure the group doesn't already exist.
+ my $newdir_path="$path\0$newdir";
+ $newdir_path=~s/^\0+//;
+ if (defined($state->{all_grp_paths_fwd}->{$newdir_path})) {
+ print "Path already exists: /" . humanize_path($newdir_path) . "\n";
+ return -1;
+ }
+
+ # Create the group
+ my $group='';
+ if ($path eq '') {
+ $group = $state->{kdb}->add_group({
+ title => $newdir,
+ icon => $DEfAULT_GROUP_ICON,
+ }); # root level group
+ } elsif (defined($state->{all_grp_paths_fwd}->{$path})) {
+ my $group_id=$state->{all_grp_paths_fwd}->{$path};
+ $group = $state->{kdb}->add_group({
+ title => $newdir,
+ group => $group_id,
+ icon => $DEfAULT_GROUP_ICON,
+ });
+ } else {
+ print "Cannot make directory at path " . humanize_path($path) . "\n";
+ return -1;
+ }
+
+ # Because we created a new group we need to refresh ours state paths
+ refresh_state_all_paths();
+ $state->{kdb_has_changed}=1;
+ RequestSaveOnDBChange();
+ return 0;
+}
+
+sub humanize_path($) {
+ my $path=shift @_;
+ $path =~ s/\//\\\//g;
+ $path =~ s/\0/\//g;
+ return $path;
+}
+
+sub cli_open($) {
+ my $path=shift @_;
+ my $key=shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ # If cli_close() does not return 0 the user decided not to close the file
+ if (cli_close() != 0) {
+ return -1;
+ }
+
+ if ( -f $path ) {
+ my $err = open_kdb($path, $key);
+ if (length($err)) {
+ print "Error opening file: $err\n";
+ }
+ } else {
+ print "Cannot open: $path\n";
+ }
+}
+
+# Get a single keypress from the user
+sub get_single_key {
+ my $drain_first = shift @_ || 1;
+ our $state;
+ my $key='';
+ $|=1; # Needed to flush STDOUT on Windows cmd prior to calling ReadMode
+ ReadMode('raw'); # Turn off controls keys
+ while($drain_first && defined( $key = ReadKey(-1) ) ) {} # Drain STDIN first
+ while (not defined ($key = ReadKey(-1))) {
+ # If the user hit ^C (SIGINT) then we need to stop
+ if (recent_sigint()) { return undef; }
+ # No key yet, but let's not eat 100% CPU while waiting, so sleep.
+ Time::HiRes::sleep(0.1);
+ }
+ ReadMode('restore');
+return $key;
+}
+# Get a single line of input from the user
+sub get_single_line {
+ our $state;
+ $|=1; # Needed to flush STDOUT on Windows cmd prior to calling ReadMode
+ my $input='';
+ while (1) {
+ my $c = get_single_key(0);
+ if (ord($c) == 3) { # ^C
+ return '';
+ } elsif (ord($c) == 127 || ord($c) == 8) { # backspace (Linux/Windows)
+ if (length($input) > 0) {
+ $input = substr($input,0,length($input) - 1);
+ print chr(8)." ".chr(8); # Erase a character
+ }
+ } elsif ($c =~ m/[\n\r]/) {
+ return $input;
+ } else {
+ $input.=$c;
+ print $c;
+ }
+ }
+}
+
+sub cli_close {
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ if ($state->{kdb_has_changed}) {
+ print "WARNING: The database has changed and was not saved.\n" .
+ "Really close it? [y/N] ";
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) ne 'y') {
+ return -1;
+ }
+ }
+
+ $state->{'kdb'}->clear();
+ new_kdb($state);
+ if (defined($state->{kdb_file_handle})) {
+ close $state->{kdb_file_handle};
+ }
+ return 0;
+}
+
+# This sets $state to a brand new, KeePassX-style, empty, unsaved database
+sub new_kdb {
+ my $state=shift @_;
+ $state->{kdb_has_changed}=0;
+ $state->{'kdb'} = File::KeePass->new;
+ #$state->{kdb_ver} = $state->{kdb}->{header}->{version}; # undef after ->new()
+ $state->{kdb_ver} = 1; # Only provide 1.x (*.kdb) features by default
+ # To be compatible with KeePassX
+ $state->{'kdb'}->add_group({ title => 'eMail' });
+ $state->{'kdb'}->add_group({ title => 'Internet' });
+ refresh_state_all_paths();
+ if (-f $state->{placed_lock_file}) { unlink($state->{placed_lock_file}); }
+ delete($state->{placed_lock_file});
+ delete($state->{kdb_file});
+ delete($state->{key_file});
+ delete($state->{master_pass});
+ cli_cd($term, {'args' => ["/"]});
+}
+
+
+sub cli_ls {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ $state->{last_ls_ents} = []; # We reload this state in this function
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ my @paths = ();
+ if (defined($params) && defined($params->{'args'}) &&
+ ref($params->{'args'}) eq 'ARRAY') {
+ @paths = @{$params->{'args'}};
+ }
+ if (scalar(@paths) == 0) { push @paths, get_pwd(); }
+ my $paths_count = scalar(@paths);
+ my @ent_matches = (); # Collects entries we've been directly asked to list
+ my @grp_paths = (); # Collects groups we've been asked to list
+ my $k=$state->{kdb};
+ foreach my $path (@paths) {
+ my $norm_path = normalize_path_string($path);
+ if (defined($state->{all_grp_paths_fwd}->{$norm_path}) || length($norm_path) < 1) {
+ push @grp_paths, $path;
+ } elsif (defined($state->{all_ent_paths_fwd}->{$norm_path})) {
+ my $tmp_ent = $k->find_entry({id=>$state->{all_ent_paths_fwd}->{$norm_path}});
+ push @ent_matches, $tmp_ent;
+ } else {
+ my @tmp_ents = shell_expansion($path);
+ foreach my $tmp_ent (@tmp_ents) {
+ if (defined($state->{all_ent_paths_fwd}->{$tmp_ent})) {
+ my $entry_id = $state->{all_ent_paths_fwd}->{$tmp_ent};
+ my $ent = $state->{kdb}->find_entry( {id=>$entry_id} );
+ push @ent_matches, $ent;
+ } elsif (defined($state->{all_grp_paths_fwd}->{$tmp_ent})) {
+ push @grp_paths, '/'.humanize_path($tmp_ent);
+ }
+ }
+ }
+ }
+
+ my $have_output=0; # Helps manage "\n" placements below.
+
+ # First present the entries that we were directly asked to list
+ if (scalar(@ent_matches) > 0) {
+ @ent_matches = sort { ncmp($a->{title},$b->{title}); } @ent_matches;
+ if ($have_output) { print "\n"; }
+ print "=== Entries ===\n";
+ print join("\n", @{get_human_entry_list(\@ent_matches, scalar(@{$state->{last_ls_ents}}))}) ."\n";
+ push @{$state->{last_ls_ents}}, @ent_matches;
+ $have_output++;
+ }
+
+ # Now present the groups that we were asked to list
+ foreach my $path (sort { ncmp($a,$b) } @grp_paths) {
+ my $norm_path = normalize_path_string($path);
+ if ($have_output) { print "\n"; }
+ if (scalar(@ent_matches) > 0 || scalar(@grp_paths) > 1) {
+ print "$path:\n";
+ $have_output++;
+ }
+ my @groups = (); my @entries = ();
+ if (length($norm_path) < 1) {
+ @groups = $k->find_groups({level=>0});
+ @entries = $k->find_entries({level => 0});
+ } else {
+ my $group_id = $state->{all_grp_paths_fwd}->{$norm_path};
+ @entries = $k->find_entries({group_id=>$group_id});
+ @entries = sort { ncmp($a->{title},$b->{title}); } @entries;
+ my $this_grp = $k->find_group({id=>$group_id});
+ if (defined($this_grp->{groups})) {
+ @groups = sort group_sort @{$this_grp->{groups}};
+ }
+ }
+ # Eliminate "system" entries inside this group that we don't want to show
+ my @good_entries = ();
+ MATCHES: foreach my $ent (@entries) {
+ #my $ent = $k->find_entry({id=>$state->{all_ent_paths_fwd}->{$match}});
+ if (defined($ent) && $ent->{'title'} eq 'Meta-Info' && $ent->{'username'} eq 'SYSTEM') {
+ next MATCHES;
+ }
+ push @good_entries, $ent;
+ }
+ @entries = @good_entries;
+ # Display the groups and entries that we have
+ if (scalar(@groups) > 0) {
+ print "=== Groups ===\n";
+ print join("\n", @{get_human_group_list(\@groups)}) . "\n";
+ $have_output++;
+ }
+ if (scalar(@entries) > 0) {
+ print "=== Entries ===\n";
+ print join("\n", @{get_human_entry_list(\@entries, scalar(@{$state->{last_ls_ents}}))}) . "\n";
+ $have_output++;
+ push @{$state->{last_ls_ents}}, @entries;
+ }
+ }
+
+ return 0;
+}
+
+# Helper function for cli_ls()
+sub get_human_group_list($) {
+ my $rGroup=shift @_;
+ my @list=();
+ foreach my $grp (@{$rGroup}) {
+ #push (@list, sprintf("%15d %s/", $grp->{id}, $grp->{title}));
+ push (@list, "$grp->{title}/");
+ push (@list, &Dumper($grp)) if ($DEBUG > 2);
+ }
+ return \@list;
+}
+
+# Helper function for cli_ls()
+sub get_human_entry_list {
+ my $rEntries=shift @_;
+ my $start_num = shift @_ || 0;
+ my @list=();
+ my $i=0;
+ my $d_len = length(scalar(@{$rEntries}) - 1 + $start_num);
+ foreach my $ent (@{$rEntries}) {
+ my $url=$ent->{url};
+ $url=~s/^https?:\/\///i;
+ $url=~s/\/+$//;
+ push (@list, sprintf("%".$d_len."d. %-40.40s %30.30s",
+ $i + $start_num, $ent->{title}, $url));
+ $i++;
+ }
+ return \@list;
+}
+
+# Routine to hook into Term::ShellUI's exit on Ctrl-D functionality
+sub eof_exit_hook {
+ our $state;
+ # We need a newline if cli_quit() will talk tothe user about saving
+ if ($state->{kdb_has_changed}) { print "\n"; }
+ # cli_quit() will handle user interaction and return a value for
+ # the exit_hook of Term::ShellUI.
+ return cli_quit($state->{term},undef);
+}
+
+# Entry attachment handling
+sub cli_attach {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint() || deny_if_readonly() || warn_if_file_changed()) {
+ return;
+ }
+
+ my $target = $params->{args}->[0];
+ my $ent=find_target_entity_by_number_or_path($target);
+ if (! defined($ent)) {
+ print "Don't see an entry at path: $target\n";
+ return -1;
+ }
+
+ my $tmp_ent = clone($ent); # Clone the entity
+ $state->{attach_changed} = 0;
+ ATTACH_INTERFACE: while (1) {
+ my @strings_keys = ();
+ if (defined($tmp_ent->{binary}) && ref($tmp_ent->{binary}) eq 'HASH') {
+ @strings_keys = sort keys %{$tmp_ent->{binary}};
+ }
+ my $strings_count = scalar(@strings_keys);
+ my $t='';
+ my $prompt = "Choose";
+ if ($strings_count > 0) {
+ $t .= show_helper_files_strings($tmp_ent,{f=>1,v=>1},'binary');
+ $t .= "$prompt: (a)dd/(e)xport/(d)elete/(c)ancel/(F)inish? ";
+ } else {
+ $t .= "No files attached.\n";
+ $t .= "$prompt: (a)dd/(c)ancel/(F)inish? ";
+ }
+ print "$t";
+ COMMAND: while (my $key=get_single_key()) {
+ if (lc($key) eq 'c' || ord($key) == 3) { # Cancel or ^C
+ print "\n";
+ delete $state->{attach_changed}; # Delete our temporary state var
+ return;
+ } elsif ($key =~ m/^[fF\r\n]$/) { # Finished (save)
+ print "\n";
+ $ent->{binary} = $tmp_ent->{binary};
+ if ($state->{attach_changed}) {
+ delete $state->{attach_changed}; # Delete our temporary state var
+ # Recycle the entry if changes were made unless --no-recycle
+ if (!(defined($opts->{'no-recycle'}) && int($opts->{'no-recycle'}))) {
+ my $errmsg = recycle_entry($state, $ent);
+ if (defined($errmsg)) { print "WARNING: $errmsg\n"; }
+ }
+ $state->{kdb_has_changed} = 1;
+ RequestSaveOnDBChange();
+ }
+ return;
+ } elsif (lc($key) eq 'd') {
+ if (defined($tmp_ent->{binary}) && ref($tmp_ent->{binary}) eq 'HASH') {
+ if ($strings_count < 2) {
+ %{$tmp_ent->{binary}} = ();
+ $state->{attach_changed}=1 if ($strings_count == 1);
+ } else {
+ print "\r". " "x60 ."\rWhich entry number do you want to delete? ";
+ my $choice = get_single_line();
+ if ($choice !~ m/^\d+$/ || $choice<1 || $choice > $strings_count) {
+ print "\nInvalid number.";
+ } else {
+ delete($tmp_ent->{binary}->{$strings_keys[$choice-1]});
+ $state->{attach_changed}=1;
+ }
+ }
+ print "\n";
+ next ATTACH_INTERFACE;
+ }
+ } elsif (lc($key) eq 'e') { # export
+ my $to_export = undef;
+ if (defined($tmp_ent->{binary}) && ref($tmp_ent->{binary}) eq 'HASH') {
+ if ($strings_count < 1) {
+ print "\n" .color('yellow'). 'Nothing it attached to export...' .
+ color('clear')."\n";
+ } elsif ($strings_count == 1) {
+ $to_export = $strings_keys[0];
+ } else {
+ print "\r". " "x60 ."\rWhich entry number do you want to export? ";
+ my $choice = get_single_line();
+ if ($choice !~ m/^\d+$/ || $choice<1 || $choice > $strings_count) {
+ print "\nInvalid number.";
+ } else {
+ $to_export = $strings_keys[$choice-1];
+ }
+ }
+ }
+ if (defined($to_export) && defined($tmp_ent->{binary}->{$to_export})) {
+ my $homedir=get_user_homedir();
+ my $filename = $to_export;
+ my @path = File::Spec->splitdir($homedir);
+ my $iv = File::Spec->catfile(@path, $filename); # homedir/filename
+ print "\n";
+ my $filename = prompt_filename_from_user($self,"Path to file: ",$iv);
+ if (! length($filename)) { next ATTACH_INTERFACE; }
+ if (lc($OSNAME) !~ m/^mswin/) { $filename=expand_tildes($filename); }
+ # If we're given a directory, assume the user wants to write into it
+ if (-e -d $filename) { $filename .= '/' . $to_export; }
+ if (-e -f $filename) {
+ print color('yellow'). "WARNING: file already exists: $filename\n" .
+ "Overwrite it? [y/N] " .color('clear');
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) ne 'y') {
+ next ATTACH_INTERFACE;
+ }
+ }
+ my $fh = new FileHandle;
+ if (! open($fh,'>', $filename)) {
+ print "ERROR: cannot write to: $filename\n";
+ next ATTACH_INTERFACE;
+ }
+ print $fh $tmp_ent->{binary}->{$to_export};
+ close $fh;
+ print "Saved to: $filename\n";
+ }
+ next ATTACH_INTERFACE;
+ } elsif (lc($key) eq 'a') { # add
+ if ($strings_count > 0 && $state->{kdb_ver} == 1) {
+ print "\n" .color('yellow').
+ 'KeePass v1 files support only one attachment per entry.' .
+ color('clear')."\n";
+ next ATTACH_INTERFACE;
+ }
+ print "\n";
+ my $filename = prompt_filename_from_user($self, "Path to file: ", "");
+ if (! length($filename)) { next ATTACH_INTERFACE; }
+ my $errmsg = do_attach_file($tmp_ent, $filename);
+ if (defined($errmsg)) {
+ print color('yellow'). "Error: $errmsg" .color('clear'). "\n";
+ }
+ next ATTACH_INTERFACE;
+ } else {
+ # Do nothing on invalid input
+ next COMMAND;
+ }
+ }
+ }
+
+ #return $initial_value;
+}
+# Helper function for cli_attach
+sub prompt_filename_from_user {
+ my $self = shift @_;
+ my $prompt = shift @_;
+ my $initial_value = shift @_;
+
+ my $term = get_prepped_readline_term();
+ # Set a completion function for files
+ my $old_compfunc=undef;
+ if (my $attr = $term->Attribs) {
+ # filename_completion_function works for Gnu Readline
+ if (defined($attr->{filename_completion_function})) {
+ $old_compfunc = $attr->{completion_entry_function};
+ $attr->{completion_entry_function} =
+ $attr->{filename_completion_function};
+ }
+ }
+ # TODO - this need more testing with Perl readlines!!!
+ if ($self->{term}->ReadLine eq 'Term::ReadLine::Perl') {
+ #$readline::rl_completion_function = "rl_filename_list";
+ $old_compfunc = $readline::rl_completion_function;
+ $readline::rl_completion_function = \&my_nongnu_complete_files;
+ } elsif ($self->{term}->ReadLine eq 'Term::ReadLine::Perl5') {
+ $old_compfunc=$Term::ReadLine::Perl5::readline::rl_completion_function;
+ $Term::ReadLine::Perl5::readline::rl_completion_function =
+ \&my_nongnu_complete_files;
+ }
+ my $filename = $term->readline($prompt, $initial_value);
+ $filename =~ s/^\s+//; $filename =~ s/\s+$//; # Trim the input
+ # Restore the old completion function
+ if (defined($old_compfunc)) {
+ if (my $attr = $term->Attribs) {
+ $attr->{completion_entry_function} = $old_compfunc;
+ }
+ if ($self->{term}->ReadLine eq 'Term::ReadLine::Perl') {
+ $readline::rl_completion_function = $old_compfunc;
+ }
+ if ($self->{term}->ReadLine eq 'Term::ReadLine::Perl5') {
+ $Term::ReadLine::Perl5::readline::rl_completion_function =
+ $old_compfunc;
+ }
+ }
+ return $filename;
+}
+# Helper function for cli_attach
+sub do_attach_file {
+ my $entry = shift @_;
+ my $path_to_file = shift @_;
+ our $state;
+
+ if (! -f $path_to_file) {
+ return "File not found at: $path_to_file";
+ }
+ my $size = -s $path_to_file;
+ if ($size > $MAX_ATTACH_SIZE) {
+ my $sizeK = sprintf("%0.02f", $size / 1024);
+ my $maxSizeK = sprintf("%0.02f", $MAX_ATTACH_SIZE / 1024);
+ return "File is too large ($sizeK versus $maxSizeK KB maximum).";
+ }
+ my $fname_short = basename($path_to_file);
+ if (defined($entry->{binary}->{$fname_short})) {
+ return "An attachement named \"$fname_short\" already exists.\n";
+ }
+ # If we get this far we're OK to attach it
+ open(my $fh,'<',$path_to_file) || return "Couldn't open file $path_to_file";
+ read($fh, my $buffer, $size);
+ close $fh;
+ if (length($buffer) != $size) {
+ return "Couldn't read entire key file contents of $path_to_file.\n";
+ }
+
+ $entry->{binary}->{$fname_short} = $buffer;
+ $state->{attach_changed}=1;
+ return undef;
+}
+# Cross-platform attempt to find a user's homedir
+sub get_user_homedir {
+ if (lc($OSNAME) =~ m/^mswin/ &&
+ defined($ENV{HOMEDRIVE}) && defined($ENV{HOMEPATH})) {
+ #return $ENV{HOMEDRIVE} . $ENV{HOMEPATH}; # Windows
+ return File::Spec->catpath($ENV{HOMEDRIVE}, $ENV{HOMEPATH}, undef);
+ }
+ my $home = $ENV{HOME} || $ENV{LOGDIR} || (getpwuid($<))[7] || undef; #Unix
+ return $home;
+}
+# Expand tildes in filename
+sub expand_tildes {
+ my $filename = shift @_;
+ # Page 253 of Perl Cookbook By Tom Christiansen, Nathan Torkington
+ # "O'Reilly Media, Inc.", Aug 21, 2003
+ $filename =~ s{ ^ ~ ( [^/]* ) }
+ { $1
+ ? (getpwnam($1))[7]
+ : ($ENV{HOME} || $ENV{LOGDIR} || (getpwuid($<))[7])
+ }ex;
+ return $filename;
+}
+
+# Copied from Term::ShellUI and modified for enhanced Windows support
+sub my_nongnu_complete_files {
+ my ($str, $line, $start) = @_;
+
+ #$self->suppress_completion_append_character();
+ if (defined($readline::rl_completer_terminator_character)) {
+ $readline::rl_completer_terminator_character='';
+ }
+ if (defined($Term::ReadLine::Perl5::readline::rl_completer_terminator_character)) {
+ $Term::ReadLine::Perl5::readline::rl_completer_terminator_character='';
+ }
+
+ my ($volume,$directories,$file) = File::Spec->splitpath($str || '.', 0 );
+ # This next line is for Windows tab completion on just "C:","D:", etc.
+ if (length($volume) && !length($directories)) { $directories='/'; }
+ my $dir = File::Spec->catpath($volume,$directories,undef);
+
+ # eradicate non-matches immediately (this is important if
+ # completing in a directory with 3000+ files)
+ $file = '' unless $str;
+ my $flen = length($file);
+
+ my @files = ();
+ if(opendir(DIR, length($dir) ? $dir : '.')) {
+ @files = grep { substr($_,0,$flen) eq $file } readdir DIR;
+ closedir DIR;
+ # eradicate dotfiles unless user's file begins with a dot
+ @files = grep { /^[^.]/ } @files unless $file =~ /^\./;
+ # reformat filenames to be exactly as user typed
+ @files = map { length($dir) ? ($dir eq '/' ? "/$_" : "$dir/$_") : $_ } @files;
+ } else {
+ print("Couldn't read dir: $!\n");
+ }
+
+ # Tack trailing slashs on dirs
+ foreach my $file_dir (@files) {
+ if (-d $file_dir && $file_dir !~m/\/$/) { $file_dir .= '/'; }
+ }
+
+ return @files;
+}
+
+sub cli_quit($$) {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ if ($state->{kdb_has_changed}) {
+ print "WARNING: The database has changed and was not saved.\n" .
+ "Really quit? [y/N] ";
+ my $key=get_single_key();
+ if (lc($key) ne 'y') {
+ print "\n";
+ return -1; # It is not OK to quit
+ }
+ }
+
+ if (-f $state->{placed_lock_file}) { unlink($state->{placed_lock_file}); }
+ delete($state->{placed_lock_file});
+ $self->exit_requested(1);
+ return 0; # It's OK to quit
+}
+
+sub cli_version($$) {
+ my $self = shift @_;
+ my $params = shift @_;
+ our $state;
+
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ # Users can provide a -f option to show the password. We use GetOptions
+ # to parse this command line, and $target holds that target.
+ my $target='';
+ my %opts=();
+ {
+ local @ARGV = @{$params->{args}};
+ my $result = &GetOptions(\%opts, 'v');
+ }
+
+ if ($opts{'v'}) {
+ print "VERSIONS\n";
+ print " * kpcli: $VERSION\n";
+ my $pv = $PERL_VERSION;
+ if (! length($pv)) {
+ $pv = $]; # For perl versions prior to 5.6.0
+ }
+ print " * Perl: $pv\n";
+ my @modules = qw(File::KeePass Term::ShellUI Term::ReadKey Term::ReadLine);
+ my @missing_modules = ();
+ foreach my $module (sort keys %OPTIONAL_PM) {
+ if ($OPTIONAL_PM{$module}->{loaded}) {
+ push @modules, $module;
+ } else {
+ push @missing_modules, $module;
+ }
+ }
+ foreach my $module (@modules) {
+ no strict 'refs';
+ my $vstr=$module . "::VERSION";
+ print " * $module: " . ${$vstr} . "\n";
+ }
+ foreach my $module (@missing_modules) {
+ print " * $module: not installed (optional)\n";
+ }
+ print "\n";
+ print "ReadLine being used: " . $term->{term}->ReadLine . "\n";
+ # Operating System
+ my $OS=$OSNAME;
+ if (lc($OSNAME) eq 'linux') {
+ my $lsbr = load_lsb_release();
+ if (defined($lsbr) && defined($lsbr->{'DISTRIB_DESCRIPTION'})) {
+ $OS .= " (" . $lsbr->{'DISTRIB_DESCRIPTION'} . ")";
+ }
+ } elsif (lc($OSNAME) eq 'mswin') {
+ if (! $OPTIONAL_PM{'Win32'}->{loaded}) {
+ runtime_load_module(\%OPTIONAL_PM,'Win32',undef);
+ }
+ if ($OPTIONAL_PM{'Win32'}->{loaded}) {
+ $OS .= " (" . Win32::GetOSDisplayName() . ")";
+ }
+ }
+ print "Operating system: $OS\n";
+ } else {
+ print "$VERSION\n";
+ }
+}
+
+sub load_lsb_release {
+ my $fh = new FileHandle;
+ if (-f '/etc/lsb-release' && open($fh,'<', '/etc/lsb-release')) {
+ my @lines = <$fh>;
+ close $fh;
+ my %d = ();
+ foreach my $l (@lines) {
+ chomp $l;
+ my ($k,$v) = split(/=/, $l, 2);
+ $d{$k} = $v;
+ }
+ return \%d;
+ }
+ return undef;
+}
+
+# Function to nag the user about saving each time the DB is modified
+sub RequestSaveOnDBChange {
+ our $state;
+
+ # If the db hasn't changed don't bother the user
+ if (! $state->{kdb_has_changed}) {
+ return -1;
+ }
+
+ # If this is a newly created file we don't bother the user with
+ # asking to save after every change.
+ if (! length($state->{kdb_file})) {
+ return -1;
+ }
+
+ print "Database was modified. Do you want to save it now? [y/N]: ";
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) ne 'y') {
+ return;
+ }
+
+ # Calling cli_save() should be silent and safe at this point.
+ return cli_save(undef);
+}
+
+sub GetMasterPasswd {
+ my $prompt = "Please provide the master password: ";
+ return GetPassword($prompt,'*');
+}
+
+sub GetMasterPasswdDoubleEntryVerify {
+ my $master_pass=GetMasterPasswd();
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+
+ if (length($master_pass) == 0) { return ''; }
+
+ my $prompt = "Retype to verify: ";
+ my $checkval = GetPassword($prompt,'*');
+ chomp $checkval;
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+ if ($master_pass ne $checkval) {
+ print "Passwords did not match...\n";
+ return undef;
+ }
+
+ return $master_pass;
+}
+
+
+sub GetPassword {
+ my $prompt = shift @_;
+ my $echo_char = shift @_ || '';
+
+ if (length($echo_char) > 1) {
+ warn "GetPassword() cannot accept an \$echo_char of more than one char.\n";
+ $echo_char='';
+ }
+
+ $|=1; # Needed to flush STDOUT on Windows cmd prior to calling ReadMode
+ ReadMode('noecho');
+ ReadMode('raw');
+ my $master_pass = '';
+ print $prompt;
+ CHAR: while (1) {
+ my $c;
+ do {
+ Time::HiRes::sleep(0.05);
+ } until defined($c = ReadKey(-1));
+ last if $c =~ m/[\n\r]/;
+ if (ord($c) == 3) { # ^C
+ print "\n";
+ ReadMode('normal');
+ kill SIGINT, $$; # Due to raw mode, I must send the SIGINT to myself.
+ return '';
+ } elsif (ord($c) == 127 || ord($c) == 8) { # backspace (Linux/Windows)
+ if (length($master_pass)) {
+ print chr(8)." ".chr(8);
+ chop($master_pass);
+ }
+ next CHAR;
+ } elsif (ord($c) == 21) { # ^U
+ my $passlen=length($master_pass);
+ print chr(8)x$passlen." "x$passlen.chr(8)x$passlen;
+ $master_pass = '';
+ next CHAR;
+ }
+ if (length($echo_char)) { print $echo_char; }
+ #print "*".ord($c);
+ $master_pass .= $c;
+ }
+ ReadMode('normal');
+ chomp $master_pass;
+ my $min_display_length = 25;
+ if (length($master_pass) < $min_display_length) {
+ print "$echo_char"x($min_display_length - length($master_pass));
+ }
+ print "\n"; $|=1;
+ if (recent_sigint()) { return undef; } # Bail on SIGINT
+ return $master_pass;
+}
+
+sub MyGetOpts {
+ my %opts=();
+ my $result = &GetOptions(\%opts, "kdb=s", "key=s", "histfile=s",
+ "help", "h", "readonly", "no-recycle", "timeout=i");
+
+ # If the user asked for help or GetOptions complained, give help and exit
+ if ($opts{help} || $opts{h} || (! int($result))) {
+ print GetUsageMessage();
+ exit;
+ }
+
+ # Allow the user to override the history file
+ if (defined($opts{histfile}) && length($opts{histfile})) {
+ our $HISTORY_FILE = $opts{histfile};
+ } else {
+ if (lc($OSNAME) =~ m/^mswin/) {
+ our $HISTORY_FILE = $ENV{USERPROFILE} . "/.$APP_NAME-history";
+ } else {
+ our $HISTORY_FILE = "~/.$APP_NAME-history";
+ }
+ }
+
+ my @errs=();
+ if ((length($opts{kdb}) && (! -e $opts{kdb}))) {
+ push @errs, "for option --kdb=<file.kbd>, the file must exist.";
+ }
+
+ if ((length($opts{key}) && (! -e $opts{key}))) {
+ push @errs, "for option --key=<file.key>, the file must exist.";
+ }
+
+ if (scalar(@errs)) {
+ warn "There were errors:\n" .
+ " " . join("\n ", @errs) . "\n\n";
+ die &GetUsageMessage();
+ }
+
+ return \%opts;
+}
+
+sub GetUsageMessage {
+ my @params = (
+ [ kdb => 'Optional KeePass database file to open (must exist).' ],
+ [ key => 'Optional KeePass key file (must exist).' ],
+ [ histfile => 'Specify your history file (or perhaps /dev/null).' ],
+ [ readonly => 'Run in read-only mode; no changes will be allowed.' ],
+ [ "timeout=i" => 'Lock interface after i seconds of inactivity.' ],
+ [ 'no-recycle' =>
+ 'Don\'t store entry changes in /Backup or "/Recycle Bin".' ],
+ [ help => 'This message.' ],
+ );
+ my $t="Usage: $APP_NAME [--kdb=<file.kdb>] [--key=<file.key>]\n" .
+ "\n";
+ foreach my $param (@params) {
+ $t .= sprintf(" %-13s %s\n", '--'.$param->[0], $param->[1]);
+ }
+ $t .= "\n" .
+ "Run kpcli with no options and type 'help' at its command prompt to learn\n" .
+ "about kpcli's commands.\n" .
+ "";
+ return $t;
+}
+
+# Because Term::ShellUI has a fixed width (%20s) for the command length
+# and we don't need nearly that much, we had to implement our own help
+# function instead of using the built-in help_call() method.
+sub my_help_call($) {
+ my $term = shift @_;
+ # @_ now holds: [Term::ShellUI->{commands}, <optional: specific command>]
+ # If the user is asking for detailed help on a specific command, do that
+ if (scalar(@_) > 1) {
+ $term->help_call(undef, @_);
+ return;
+ }
+ # If no specific command was requested, show the command summaries
+ my $help = $term->get_all_cmd_summaries($term->commands());
+ $help =~ s/^ {12}//gm; # Trim some leading spaces off of each line of output
+ print $help;
+ print "\n" .
+ "Type \"help <command>\" for more detailed help on a command.\n";
+ return 0;
+}
+
+########################################################################
+# Command Completion Routines ##########################################
+########################################################################
+
+sub complete_groups {
+ my $self = shift;
+ my $cmpl = shift;
+ our $state;
+
+ # Place the string (token) that the user is trying to complete into $path.
+ my $path = $cmpl->{tokens}->[$cmpl->{tokno}];
+ # If the cursor isn't at the end of the sting, chop $path to that length.
+ if (length($path) > $cmpl->{tokoff}) {
+ $path = substr($path, 0, $cmpl->{tokoff});
+ }
+
+ my $srch_path=normalize_path_string($path);
+ my @possibles = ();
+ # If the path ends in a "/" (a directory) then we are looking for subirs,
+ # else we are just looking to tab-complete a partial dir-name at this level.
+ # Used only for /<tab> (the root dir is a special case)
+ if ($srch_path =~ m/^$|^[.]$/) {
+ @possibles = grep(/^[^\0]+$/,
+ sort keys %{$state->{all_grp_paths_fwd}});
+ # Used only for /..any/thing../<tab> (non-root directories)
+ } elsif (defined($state->{all_grp_paths_fwd}->{$srch_path})) {
+ # If the user is sitting on a dir without the trailing /, return that
+ # now as the only option (/dir1/dir2<tab>). We do this because the
+ # code later does not handle this case well at all. We do, however,
+ # have to not do this for things like "ls <tab>" which is why we test
+ # for length($path).
+ if (length($path) && $path !~ m/\/$/) {
+ __my_suppress_completion_append_character($self);
+ return [ $cmpl->{str} . "/" ];
+ }
+ @possibles = grep(/^\Q$srch_path\E\0[^\0]+$/,
+ sort keys %{$state->{all_grp_paths_fwd}});
+ # Used for /../any/thing../foo<tab>
+ } else {
+ @possibles = grep(/^\Q$srch_path\E[^\0]*$/,
+ sort keys %{$state->{all_grp_paths_fwd}});
+ }
+
+ # Loop over the possibilites doing required magic...
+ my @results=();
+ foreach my $opt (@possibles) {
+ $opt=humanize_path($opt);
+ if ($path =~ m/^(\/+)/) { # Absolute path (easy case!)
+ $opt=$1.$opt;
+ } else { # Path relative to pwd
+ my $user_new_path=get_pwd() . "/" . $path;
+ my $new_dir=normalize_path_string($user_new_path);
+ # If the user's input does not resolve to a fully qualified
+ # path then we need to pop off the last bit to get to that.
+ if (! defined($state->{all_grp_paths_fwd}->{$new_dir})) {
+ my @path=split(/\0/, $new_dir); pop @path;
+ $new_dir = join("\0", @path);
+ }
+ $new_dir = humanize_path($new_dir);
+ # Dirname requires "../." (Trailing dot) to give ".." in those cases!
+ my $dirname_path=$path;
+ if ($path =~ m/\/$/) { $dirname_path .= "."; }
+ my $path_to_put_back=dirname($dirname_path) . "/";
+ if ($new_dir eq '') { # All the way at the root level
+ $opt = $path_to_put_back . $opt;
+ } else { # Some non-root level (deeper)
+ $opt=~s/^$new_dir\//$path_to_put_back/;
+ }
+ # Lop the leading "./" off the head if it was not user supplied
+ if ($path !~ m/^[.]\// && $opt =~ m/^[.]\//) { $opt =~ s/^[.]\///; }
+ # If the user did supply a leading "./" and we missed it, add it
+ if ($path =~ m/^[.]\// && $opt !~ m/^[.]\//) { $opt = "./$opt"; }
+ }
+ push @results, "$opt/";
+ }
+
+ # Foreach possibility, we have to strip off the parts that are already
+ # completed and then prepend the part that is looking to be completed,
+ # from $cmpl->{str}.
+ my @completions = ();
+ foreach my $possibility (@results) {
+ $possibility = normalize_path_string($possibility);
+ $possibility =~ s/^\Q$srch_path\E\0?//;
+ if (length($possibility)) {
+ $possibility = $cmpl->{str} . humanize_path($possibility);
+ if ($possibility !~ m/\/$/) { $possibility .= '/'; }
+ push @completions, $possibility;
+ }
+ }
+
+ # If we are about to return only one completion result, we need to first
+ # test to see if any other subdirs are below it and, if so, suppress the
+ # completion append character so that the user can keep tab completing
+ # into lower level directories.
+ if (scalar(@completions) == 1) {
+ my @all_subdirs = grep(/^\Q$srch_path\E[^\0]*(\0[^\0]+)?/,
+ sort keys %{$state->{all_grp_paths_fwd}});
+ if (scalar(@all_subdirs) > 0) {
+ __my_suppress_completion_append_character($self);
+ }
+ }
+
+ return \@completions;
+}
+
+sub complete_entries {
+ my $self = shift;
+ my $cmpl = shift;
+ our $state;
+
+ # Place the string (token) that the user is trying to complete into $path.
+ my $path = $cmpl->{tokens}->[$cmpl->{tokno}];
+ # If the cursor isn't at the end of the sting, chop $path to that length.
+ if (length($path) > $cmpl->{tokoff}) {
+ $path = substr($path, 0, $cmpl->{tokoff});
+ }
+
+ my $srch_path=normalize_path_string($path);
+ my @entries = grep(/^\Q$srch_path\E\0?[^\0]*$/,
+ sort keys %{$state->{all_ent_paths_fwd}});
+ # User can tab exactly at a directory level and with or without the
+ # trailing slash, and so we need to normalize that, always ensuring that
+ # the slash is inserted. We do that by setting the dir_level_sep here
+ # and always removing any trailing slash sent in below (\0 on srch_path).
+ # We do, however, have to not do this for things like "show <tab>" which
+ # is why we test for length($path).
+ my $dir_level_sep = '';
+ if (defined($state->{all_grp_paths_fwd}->{$srch_path}) &&
+ length($path) && $path !~ m/\/$/) {
+ $dir_level_sep = '/';
+ }
+ # Foreach possibility, we have to strip off the parts that are already
+ # completed and then prepend the part that is looking to be completed
+ # by Term::ShellUI, from $cmpl->{str}.
+ my @completions = ();
+ foreach my $possibility (@entries) {
+ $possibility =~ s/^\Q$srch_path\E\0?//;
+ if (length($possibility)) {
+ $possibility = $cmpl->{str} .$dir_level_sep. humanize_path($possibility);
+ push @completions, $possibility;
+ }
+ }
+
+ return \@completions;
+}
+
+sub complete_groups_and_entries {
+ my $self = shift;
+ my $cmpl = shift;
+
+ my $groups = complete_groups($self,$cmpl);
+ my $entries = complete_entries($self,$cmpl);
+
+ # Merge and sort the groups and entries
+ my @completions = sort (@{$groups}, @{$entries});
+ return \@completions;
+}
+
+# In Term::ReadLine::Gnu, suppress_completion_append_character() works,
+# but in Term::ReadLine::Perl and Term::ReadLine::Perl5 it does not, and
+# so we get to the outcome via $readline::rl_completer_terminator_character.
+sub __my_suppress_completion_append_character($self) {
+ my $self = shift;
+ if ($self->{term}->ReadLine eq 'Term::ReadLine::Gnu') {
+ $self->suppress_completion_append_character();
+ } else {
+ # For Term::ReadLine::Perl
+ # From Term/ReadLine/readline.pm
+ # - package readline;
+ if (defined($readline::rl_completer_terminator_character)) {
+ $readline::rl_completer_terminator_character='';
+ }
+ # For Term::ReadLine::Perl5
+ # From Term/ReadLine/Perl5/readline.pm
+ # - package Term::ReadLine::Perl5::readline;"
+ if (defined(
+ $Term::ReadLine::Perl5::readline::rl_completer_terminator_character)) {
+ $Term::ReadLine::Perl5::readline::rl_completer_terminator_character='';
+ }
+ }
+}
+
+########################################################################
+# Rijndael encrypt/decrypt routines - borrowed from File::KeePass ######
+########################################################################
+sub decrypt_rijndael_cbc {
+ my ($buffer, $key, $enc_iv) = @_;
+ my $cipher = Crypt::Rijndael->new($key, Crypt::Rijndael::MODE_CBC());
+ $cipher->set_iv($enc_iv);
+ $buffer = $cipher->decrypt($buffer);
+ my $extra = ord(substr $buffer, -1, 1);
+ substr($buffer, length($buffer) - $extra, $extra, '');
+ return $buffer;
+}
+sub encrypt_rijndael_cbc {
+ my ($buffer, $key, $enc_iv) = @_;
+ my $cipher = Crypt::Rijndael->new($key, Crypt::Rijndael::MODE_CBC());
+ $cipher->set_iv($enc_iv);
+ my $extra = (16 - length($buffer) % 16) || 16; # pad so we can trim
+ $buffer .= chr($extra) for 1 .. $extra;
+ return $cipher->encrypt($buffer);
+}
+sub composite_master_pass($$) {
+ my ($pass, $key_file) = @_;
+
+ # composite password in case of key file
+ if (defined $key_file and length($key_file) and -f $key_file) {
+ # KeePass v2.03 and higher has native key file support, and so we
+ # use that if we have it.
+ if (version->parse($File::KeePass::VERSION) >= version->parse('2.03')) {
+ return [$pass, $key_file];
+ }
+ # TODO - at some point, when File::KeePass v2.03 is very mainstream,
+ # the code to the end of this if block should be removed. It allowed
+ # support for key files for *.kdb files before File::KeePass supported
+ # that natively. File::KeePass now also supports that for *.kdbx.
+ open(my $fh,'<',$key_file) || die "Couldn't open key file $key_file: $!\n";
+ my $size = -s $key_file;
+ read($fh, my $buffer, $size);
+ close $fh;
+ if (length($buffer) != $size) {
+ die "Couldn't read entire key file contents of $key_file.\n";
+ }
+
+ $pass = substr(sha256($pass),0,32);
+ if ($size == 32) {
+ $pass .= $buffer;
+ } elsif ($size == 64) {
+ for (my $i = 0; $i < 64; $i += 2) {
+ $pass .= chr(hex(substr($buffer,$i,2)));
+ }
+ } else {
+ $pass .= substr(sha256($buffer),0,32);
+ }
+ # TODO - this marks the "end of the block" noted above.
+ }
+
+ return $pass;
+}
+sub put_master_passwd($) {
+ my $master_pass = shift @_;
+ our $state;
+ $state->{'master_pass_key'}='';
+ $state->{'master_pass_key'} .= chr(int(255 * rand())) for 1..16;
+ $state->{'master_pass_enc_iv'}='';
+ $state->{'master_pass_enc_iv'} .= chr(int(255 * rand())) for 1..16;
+ $master_pass='CLEAR:' . $master_pass;
+ $state->{'master_pass'}=encrypt_rijndael_cbc($master_pass,
+ $state->{'master_pass_key'}, $state->{'master_pass_enc_iv'});
+ return 0;
+}
+sub get_master_passwd() {
+ our $state;
+ my $master_pass=decrypt_rijndael_cbc($state->{master_pass},
+ $state->{'master_pass_key'}, $state->{'master_pass_enc_iv'});
+ if ($master_pass=~s/^CLEAR://) {
+ return $master_pass;
+ } else {
+ die "Failed to properly decrypt my copy of the master password.\n";
+ }
+}
+
+# This routine checks to see if the file has changed on disk and warns if so
+sub warn_if_file_changed {
+ our $state;
+
+ my $file = $state->{kdb_file};
+ if (! length($file)) { return 0; } # If no file was opened, don't warn
+ my $file_md5 = Digest::file::digest_file_hex($file, "MD5");
+ if ($state->{kdb_file_md5} ne $file_md5) {
+ print color('bold yellow') . "WARNING:" . color('clear') .
+ color('red') .
+ " The file has changed on disk since kpcli opened it!\n" .
+ " It may be opened elsewhere. Continue anyway? [y/N] " .
+ color('clear');
+ my $key=get_single_key();
+ print "\n";
+ if (lc($key) ne 'y') {
+ return -1;
+ }
+ }
+
+ return 0;
+}
+
+sub generateKeyfileContents($) {
+ my $length = shift;
+ my $password = '';
+ my @normal_chars=('a'..'z','A'..'Z',0..9);
+ # all printable non-alnum chars except space (0x20), backspace (0x5c),
+ # and backtick (0x60)
+ my @special_chars=map(chr, 0x21 .. 0x2f, 0x3a .. 0x40,
+ 0x5b, 0x5d .. 0x5f, 0x7b .. 0x7e);
+ my $charset = join('', (@normal_chars, at special_chars));
+ while (length($password) < $length) {
+ $password .= substr($charset, (int(rand(length($charset)))), 1);
+ }
+ return $password;
+}
+
+sub generatePassword {
+ my $be_silent = shift @_ || 0;
+ my $password = generatePasswordFromDict($be_silent);
+ if (! length($password)) {
+ if (! $be_silent) {
+ print color('yellow') .
+ "Generated random characters instead of a words-based password.\n" .
+ color('clear');
+ }
+ $password = generatePasswordGobbledygook(20);
+ }
+ return $password;
+}
+
+sub generatePasswordGobbledygook {
+ my $length = shift;
+ my @normal_chars=('a'..'z','A'..'Z',0..9);
+ my @special_chars=qw(_);
+ my $charset = join('', (@normal_chars, at special_chars));
+ # Generate the password
+ my $password = '';
+ while (length($password) < $length) {
+ $password .= substr($charset, (int(rand(length($charset)))), 1);
+ }
+ # Make sure that at least one special character appears
+ my $sccc=join('', @special_chars);
+ if ($password !~ m/[\Q$sccc\E]/) {
+ my $sc=$special_chars[int(rand(length($sccc)))];
+ substr($password,int(rand(length($password))), 1, $sc);
+ }
+ return $password
+}
+
+sub genPassInteractiveHelper($$) {
+ my $mode = shift @_;
+ my $len = shift @_;
+ if ($mode eq 'g') {
+ return generatePasswordGobbledygook($len);
+ } elsif ($mode eq 'w') {
+ return generatePasswordFromDict(0);
+ } else {
+ return undef;
+ }
+}
+
+sub generatePasswordInteractive($) {
+ my $default_passwd_len = shift @_;
+
+ print "\n";
+ print "INTERACTIVE PASSWORD GENERATION:\n";
+ print "<n>o/<ret> Do not accept current password, generate another one.\n";
+ print "<y>es Accept current password.\n\n";
+ print "<t>oggle Toggle between random characters and word-based mode.\n";
+ print "<c>ancel Abort interactive password generation mode.\n";
+ print "And in random characters mode:\n";
+ print " +/- Increase/decrease password length. " .
+ "May be prefixed with a count.\n";
+ print " [n]= Set password length to [n] chars. If not given, " .
+ "resets to $DEFAULT_PASSWD_LEN chars.\n";
+
+ my $pw_is_ok = 0;
+ my $len = $default_passwd_len;
+
+ my $mode = 'g';
+ my $pw = genPassInteractiveHelper($mode,$len);
+ do {
+ my $prompt_format = "%s +/-/= n/y/t/c ";
+ if ($mode eq 'w') { $prompt_format = "%s n/y/t/c "; }
+ my $prompt = sprintf($prompt_format, $pw);
+ print $prompt;
+ my $input = '';
+ my $input_complete = 0;
+ do {
+ if (recent_sigint()) { return undef; }
+ my $k = get_single_key();
+ if (recent_sigint()) { return undef; }
+ my $kc = unpack("C", $k);
+ if ($kc == 0x03) { # ^C
+ return undef;
+ } elsif ($kc == 127 || $kc == 8) { # backspace (Linux/Windows)
+ if (length($input) > 0) {
+ $input = substr($input,0,length($input) - 1);
+ print chr(8)." ".chr(8); # Erase a character
+ #print $k; # Print the backspace key to erase a character
+ }
+ } elsif ($kc == 0x0a) {
+ $input = '';
+ $input_complete = 1;
+ } else {
+ # Validate character input then process it
+ if ($input eq '' && $k =~ m/^[tcny]$/
+ || ($mode eq 'g' && $k =~ m/^[\d\+\-=]$/ && $input =~ m/^\d*$/)
+ ) {
+ $input .= $k;
+ if ($mode eq 'g' && $input =~ /^(\d+)?[+=-]$/) {$input_complete=1;}
+ if ($input =~ /^[tcny]$/ ) { $input_complete = 1; }
+ if ($input =~ /^\d/) { print $k; }
+ }
+ }
+ } while (!$input_complete);
+ print "\n";
+
+ if (lc($input) eq 't') {
+ if ($mode eq 'g') { $mode = 'w' } else { $mode = 'g'; }
+ $input = 'n';
+ }
+
+ if ($input eq 'y') {
+ length($pw) and $pw_is_ok = 1;
+ } elsif ($mode eq 'g' && $input =~ /^(\d+)?[+=-]$/) {
+ my $new_len = $DEFAULT_PASSWD_LEN;
+ if ($input =~ /^(\d+)?=$/) {
+ $new_len = $1 || $DEFAULT_PASSWD_LEN;
+ } elsif ($input =~ /^(\d+)?([+-])$/) {
+ my $v = $1 ? $1 : 1;
+ $v *= $2 eq '-' ? -1 : 1;
+ $new_len = $len + $v;
+ }
+ if ($new_len < $DEFAULT_PASSWD_MIN) {
+ $len = $DEFAULT_PASSWD_MIN;
+ } elsif ($new_len > $DEFAULT_PASSWD_MAX) {
+ $len = $DEFAULT_PASSWD_MAX;
+ } else {
+ $len = $new_len;
+ printf "[%s%s%s]\n",
+ '-' x ($len - $DEFAULT_PASSWD_MIN), '|',
+ '-' x ($DEFAULT_PASSWD_MAX - $len);
+ }
+ if (recent_sigint()) { return undef; }
+ $pw = genPassInteractiveHelper($mode,$len);
+ } elsif ($input eq 'c') {
+ return undef; # Return undef on cancel
+ } elsif ($input eq 'n' || !$input) {
+ if (recent_sigint()) { return undef; }
+ $pw = genPassInteractiveHelper($mode,$len);
+ }
+ } while (!$pw_is_ok);
+ return($pw);
+}
+
+# Inspired by http://xkcd.com/936/
+sub generatePasswordFromDict($) {
+ my $be_silent = shift @_ || 0;
+ my @words=();
+ my $fh = new FileHandle;
+ my @dict_files = qw(/usr/share/dict/words /usr/dict/words);
+ DICTS: foreach my $dictfile (@dict_files) {
+ if (-f $dictfile && -r $dictfile && open($fh,'<', $dictfile)) {
+ @words = <$fh>;
+ close($fh);
+ last DICTS;
+ }
+ }
+ if (scalar(@words) < 10000) {
+ if (! $be_silent) {
+ print color('yellow') .
+ "No adequate dictionary found to generate a words-based password.\n".
+ "These locations were checked:\n - ".join("\n - ", @dict_files)."\n" .
+ color('clear');
+ }
+ return undef;
+ }
+ my $length_tries = 12;
+ my $password='';
+ do {
+ my @passwords = ();
+ my $word_tries=20;
+ while (scalar(@passwords) < 4 && $word_tries-- > 0) {
+ my $word = $words[int(rand(scalar(@words)))];
+ chomp $word;
+ $word =~ s/[^a-zA-Z0-9]//g;
+ if (length($word) > 7) { next; } # Don't care for big words
+ push @passwords, $word;
+ }
+ if (scalar(@passwords) == 4) {
+ $password=join('.', @passwords);
+ }
+ #warn "LHHD: $password\n";
+ if (length($password) < 27) { $password=''; } # Need a decent overall size
+ } until (length($password) || $length_tries-- < 0);
+
+ return $password;
+}
+
+# Adapted from http://docstore.mik.ua/orelly/perl/cookbook/ch06_10.htm
+sub glob2pat {
+ my $globstr = shift;
+ my %patmap = (
+ '*' => '[^\0]*',
+ '?' => '[^\0]',
+ '[' => '[',
+ ']' => ']',
+ );
+ $globstr =~ s{(.)} { $patmap{$1} || "\Q$1" }ge;
+ return '^' . $globstr . '$';
+}
+
+sub shell_expansion($) {
+ my $shell_path = shift @_;
+ our $state;
+ my $regex = glob2pat(normalize_path_string($shell_path));
+ $regex = qr/$regex/;
+ my @grps_and_ents = ();
+ push @grps_and_ents, keys %{$state->{all_ent_paths_fwd}};
+ push @grps_and_ents, keys %{$state->{all_grp_paths_fwd}};
+ @grps_and_ents = sort { ncmp($a,$b); } @grps_and_ents;
+ my @matches = grep(/${regex}/, @grps_and_ents);
+ # Eliminate "system" things that we don't want to include
+ my @good_matches = ();
+ MATCHES: foreach my $match (@matches) {
+ if (defined($state->{all_ent_paths_fwd}->{$match})) {
+ my $ent = $state->{kdb}->find_entry({id=>$state->{all_ent_paths_fwd}->{$match}});
+ if (defined($ent) && $ent->{'title'} eq 'Meta-Info' && $ent->{'username'} eq 'SYSTEM') {
+ next MATCHES;
+ }
+ }
+ push @good_matches, $match;
+ }
+ return @good_matches;
+}
+
+#########################################################################
+# Setup signal handling #################################################
+#########################################################################
+sub setup_signal_handling {
+ our $state;
+
+ # We only worry with signal handling for Term::ReadLine::Gnu
+ if ($state->{'term'}->{term}->ReadLine ne 'Term::ReadLine::Gnu') {
+ return 0;
+ }
+
+ # We don't want Term::Readline::Gnu catching signals, except for WINCH.
+ # I really don't understand why, but I know via experimentation.
+ my $term = $state->{'term'}->{term};
+ $term->Attribs->{catch_signals} = 0;
+ $term->Attribs->{catch_sigwinch} = 1; # Window resizes
+ $term->clear_signals();
+
+ # Install a signal handler to catch SIGINT (^C). Unsafe signal handling
+ # (through POSIX::SigAction) is required to deal with Term::ReadLine::Gnu.
+ # We don't even try for other readlines due to their limited functionality.
+ my $handler_SIGINT = sub {
+ our $state;
+ # We could be using one of a couple of ReadLine terminals; the one
+ # from Term::ShellUI ($state->{'term'}->{term}) or one from one of
+ # our cli_NNN commands ($state->{active_readline}). We will assume
+ # the Term::ShellUI one here, and override that below if needed.
+ my $term = $state->{'term'}->{term};
+
+ # Record in our state when a SIGINT was last received
+ $state->{signals}->{INT} = [gettimeofday];
+
+ # We need to pull the Carp longmess to see if we're sitting in a
+ # a cli_XXXX function instead of at a readline prompt.
+ my $mess = longmess();
+ #print Dumper( $mess );
+ # At some point, Term::ShellUI started wrapping my cli_XXX() routines
+ # in eval{}, which hid the cli_\w+() from the longmess, and so I had
+ # to add a second condition here.
+ if ($mess =~ m/(main::(cli_\w+)\(|Term::ShellUI::call_cmd\()/) {
+ #warn "It appears that SIGINT was called from $1\n";
+ #warn "LHHD: $mess\n";
+ # If the cli_NNN has an active_readline we need to work with it
+ if (defined($state->{active_readline})) {
+ #warn "LHHD: in INT with active_readline\n";
+ my $term = $state->{active_readline};
+ $term->free_line_state();
+ $term->cleanup_after_signal();
+ $term->reset_after_signal();
+ $term->Attribs->{line_buffer}=''; # Clear the buffer
+ $term->Attribs->{done}=1; # Ask readline to return immediately
+ }
+ } else { # If not in a cli_XXX(), assume a Term::ShellUI prompt
+ my $yellow=color('yellow');
+ my $clear=color('clear');
+ #$term->echo_signal_char(SIGINT); # Puts ^C on the next line. :(
+ # Trial and error on these readline_state values... :(
+ #warn "LHHD: " . sprintf($term->Attribs->{readline_state}) . "\n";
+ if ($term->Attribs->{readline_state} == 262374) {
+ print "^C$yellow - use Ctrl-g to stop history search.$clear\n";
+ } else {
+ print "^C$yellow - use the \"quit\" command to exit.$clear\n";
+ }
+ $term->free_line_state();
+ $term->cleanup_after_signal();
+ $term->reset_after_signal();
+ $term->Attribs->{line_buffer}=""; # Clear the input buffer
+ $term->forced_update_display(); # Force update the display
+ }
+ return 0;
+ };
+ sigaction(SIGINT, new POSIX::SigAction($handler_SIGINT));
+ #$SIG{INT} = $handler_SIGINT; # Works only if $ENV{PERL_SIGNAL}='unsafe'
+ #https://groups.google.com/forum/#!topic/perl.perl5.porters/fNJdyyZh7Wc
+
+ # Handle signal CONT - continue signal (resuming after Ctrl-Z).
+ my $handler_SIGCONT = sub {
+ our $state;
+ my $term = $state->{'term'}->{term};
+ my $mess = longmess();
+ # At some point, Term::ShellUI started wrapping my cli_XXX()
+ # routines in eval{}, which hid the cli_\w+() from the longmess,
+ # and so I had to add a second condition here.
+ if ($mess =~ m/(main::(cli_\w+)\(|Term::ShellUI::call_cmd\()/ &&
+ defined($state->{active_readline})) {
+ $term = $state->{active_readline};
+ }
+ $term->cleanup_after_signal();
+ $term->reset_after_signal();
+ $term->forced_update_display(); # Force update the display
+ };
+ sigaction(SIGCONT, new POSIX::SigAction($handler_SIGCONT));
+ #$SIG{CONT} = $handler_SIGCONT; # Works only if $ENV{PERL_SIGNAL}='unsafe'
+}
+
+#########################################################################
+# Setup timeout handling (--timeout=N) ##################################
+#########################################################################
+sub setup_timeout_handling {
+ our $state;
+ $state->{last_activity_time}=time;
+ our $def_call_command = \&Term::ShellUI::call_command;
+ if ($state->{OPTIONAL_PM}->{'Sub::Install'}->{loaded}) {
+ Sub::Install::reinstall_sub({
+ into => "Term::ShellUI",
+ as => 'call_command',
+ code =>
+ sub {
+ our $state;
+ my $self = $_[0];
+ my $parms = $_[1];
+ my $cmd = $self->get_cname($parms->{cname});
+ my $idletime = abs($state->{last_activity_time} - time);
+ my $timeout_exempt=0;
+ my $all_commands = $self->commands();
+ if (defined($all_commands->{$cmd}) &&
+ defined($all_commands->{$cmd}->{timeout_exempt}) &&
+ $all_commands->{$cmd}->{timeout_exempt}) {
+ $timeout_exempt=1;
+ }
+ if (defined($state->{kdb_file}) && length($state->{kdb_file}) &&
+ ($timeout_exempt == 0) && ($idletime > $opts->{timeout})) {
+ print "You were idle for more than $opts->{timeout} seconds...\n";
+ # GetMasterPasswd()=from user; get_master_passwd()=for kdb file
+ if (GetMasterPasswd() ne get_master_passwd()) {
+ print "Wrong password.\n";
+ return -1;
+ }
+ $idletime = 0; # Reset idle time on successful password.
+ }
+ # Update the state->{last_activity_time} only if not already past
+ # the timeout; the command could have been one one of the the
+ # timeout_exempt ones, as defined in main Term::SehllUI data.
+ if ($idletime <= $opts->{timeout}) {
+ $state->{last_activity_time}=time;
+ }
+ # Call Term::ShellUI::call_command()
+ our $def_call_command;
+ return &$def_call_command(@_);
+ },
+ });
+ }
+}
+
+# Code consolidation function to runtime-load optional perl modules
+sub runtime_load_module($$$) {
+ my $rOPTIONAL_PM = shift @_;
+ my $module = shift @_;
+ my $rImportList = shift @_ || undef;
+ my $iltxt = '';
+ if (defined($rImportList) && ref($rImportList) ne 'ARRAY') {
+ die "The rImportList param to runtime_load_module() must be an ARRAY ref\n";
+ }
+ if (defined($rImportList) && ref($rImportList) eq 'ARRAY' &&
+ scalar(@$rImportList) > 0) {
+ $iltxt = "('" . join("','", @{$rImportList}) . "')";
+ }
+ if (eval "require $module;$module->import($iltxt);1;" eq 1) {
+ $rOPTIONAL_PM->{$module}->{loaded} = 1;
+ return 1;
+ } else {
+ $rOPTIONAL_PM->{$module}->{loaded} = 0;
+ return 0;
+ }
+}
+
+# This routine runs down the list of our preferred Term::ReadLine::*
+# modules and returns a new object from the first one that we find.
+sub get_readline_term($$) {
+ my $rOPTIONAL_PM = shift @_;
+ my $app_name = shift @_;
+ my @rl_modules = ();
+ # The list of readlines that we support
+ push @rl_modules, 'Term::ReadLine::Gnu';
+ push @rl_modules, 'Term::ReadLine::Perl';
+ push @rl_modules, 'Term::ReadLine::Perl5';
+ my $rl_term = undef;
+ my $hold_TERM=undef;
+ MODULE: foreach my $module (@rl_modules) {
+ # On MS Windows, Term::ReadLine::Perl and Term::ReadLine::Perl5 are
+ # pretty good terminals but they behave badly if the environment
+ # variable TERM=dumb, and so we override that here if needed.
+ if (lc($OSNAME) =~ m/^mswin/ && $module =~ m/^Term::ReadLine::Perl5?/ &&
+ $ENV{'TERM'} eq 'dumb') {
+ $hold_TERM=$ENV{'TERM'};
+ $ENV{'TERM'} = 'vt102';
+ }
+ if (runtime_load_module($rOPTIONAL_PM,$module,undef) eq 1) {
+ # These SGI{'__WARN__'} shenanigans are to suppress:
+ # WARNING: Use of inherited AUTOLOAD for non-method
+ # Term::ReadLine::Gnu::ornaments() is deprecated at
+ # /usr/lib/perl5/Term/ReadLine/Gnu.pm line 250.
+ # Hopefully, newer versions of Term::ReadLine::Gnu will fix this.
+ $SIG{'__WARN__'} =
+ sub { warn $_[0] unless (caller eq "Term::ReadLine::Gnu"); };
+ $rl_term = eval "$module->new('$app_name');";
+ delete $SIG{'__WARN__'};
+ last MODULE;
+ } else {
+ #warn "Loading $module failed\n";
+ if (defined($hold_TERM)) { $ENV{'TERM'} = $hold_TERM; $hold_TERM=undef; }
+ }
+ }
+
+ #if (! defined($rl_term)) { return undef; }
+ if (! defined($rl_term)) {
+ die "No usable Term::ReadLine::* modules found.\n" .
+ "This list tried: " . join(', ', @rl_modules) . "\n";
+ }
+
+ # I don't like readline ornaments in kpcli
+ if (lc($OSNAME) =~ m/^mswin/ && $rOPTIONAL_PM->{'Capture::Tiny'}->{loaded}) {
+ # On MS Windows, the RLTERM->ornaments() call causes a warning about
+ # not having a termcap file. It seems hamless and so we suppress that
+ # message if we have Capture::Tiny available.
+ my ($out, $err, @result) = capture( sub { $rl_term->ornaments(0); } );
+ if (length($err) && $err !~ m/^cannot find termcap/i) { warn $err; }
+ } else {
+ # WARNING: Use of inherited AUTOLOAD for non-method
+ # Term::ReadLine::Gnu::ornaments() is deprecated
+ # at line <two lines below>.
+ # This "no warnings" is to stop that, but the same warning
+ # still comes from Term::ReadLine::Gnu at line 250 with perl
+ # v5.14.2 and Term::ReadLine::Gnu 1.20-2. That is suppressed
+ # in a different way, just above in this same function.
+ no warnings 'once';
+ $rl_term->ornaments(0);
+ }
+
+ # I'm not sure that these are only needed on Windows, but I know they
+ # are not needed on Linux so I'm trying to keep the scope narrow.
+ if (lc($OSNAME) =~ m/^mswin/ &&
+ $rl_term->ReadLine =~ m/Term::ReadLine::Perl5?/) {
+ # For Term::ReadLine::Perl and Term::ReadLine::Perl we set
+ # $readline::rl_scroll_nextline=0 on MS Windows.
+ if (defined($readline::rl_scroll_nextline)) {
+ $readline::rl_scroll_nextline=0;
+ }
+ if (defined($Term::ReadLine::Perl5::readline::rl_scroll_nextline)) {
+ $Term::ReadLine::Perl5::readline::rl_scroll_nextline=0;
+ }
+ }
+
+ # History
+ # The Term::ReadLine::Perl* modules shove values into the history
+ # file automtically. That causes two problems for kpcli:
+ # 1) Term::ShellUI calls $term->addhistory() itself for each CLI
+ # command that it wants to store ih history (duplicates).
+ # 2) We use readline() in places like cli_new, cli_edit, etc. and
+ # we do not want all those inputs in the history file (cruft).
+ if ($rl_term->ReadLine eq 'Term::ReadLine::Perl') {
+ no strict 'refs';
+ *readline::add_line_to_history = sub { return undef; };
+ }
+ if ($rl_term->ReadLine eq 'Term::ReadLine::Perl5') {
+ no strict 'refs';
+ *Term::ReadLine::Perl5::readline::add_line_to_history = sub {return undef;}
+ }
+
+ if (defined($hold_TERM)) { $ENV{'TERM'} = $hold_TERM; }
+
+ return $rl_term;
+}
+
+# Use simple magic recipes to identify relevant file types
+sub magic_file_type($) {
+ my $filename = shift @_;
+ my $fh=new FileHandle;
+ my $header='';
+ if (open($fh, '<', $filename)) {
+ my $n = read $fh, $header, 4;
+ close $fh;
+ }
+ # KeePass
+ # Recipe from https://github.com/glensc/file/blob/master/magic/Magdir/keepass
+ # 0 lelong 0x9AA2D903 Keepass password database
+ # >4 lelong 0xB54BFB65 1.x KDB
+ # >>48 lelong >0 \b, %d groups
+ # >>52 lelong >0 \b, %d entries
+ # >>8 lelong&0x0f 1 \b, SHA-256
+ # >>8 lelong&0x0f 2 \b, AES
+ # >>8 lelong&0x0f 4 \b, RC4
+ # >>8 lelong&0x0f 8 \b, Twofish
+ # >>120 lelong >0 \b, %d key transformation rounds
+ # >4 lelong 0xB54BFB67 2.x KDBX
+ if ($header =~ m/^\x03\xd9\xa2\x9a/) {
+ return 'keepass';
+ }
+ # Password Safe v3
+ # Recipe from https://github.com/glensc/file/blob/master/magic/Magdir/pwsafe
+ if ($header =~ m/^PWS3/) {
+ return 'pws3';
+ }
+ return undef;
+}
+
+########################################################################
+# Unix-style, "touch" a file
+########################################################################
+sub touch_file {
+ my $filename = shift @_;
+ if (! -f $filename) {
+ my $fh=new FileHandle;
+ open($fh, "> $filename");
+ close($fh);
+ }
+ my $sig_pipe_store=$SIG{'PIPE'};
+ $SIG{'PIPE'} = 'IGNORE';
+ my $now=time;
+ my $retval=utime $now, $now, $filename;
+ $SIG{'PIPE'} = $sig_pipe_store;
+return($retval);
+}
+
+########################################################################
+# POD ##################################################################
+########################################################################
+
+=head1 NAME
+
+kpcli - A command line interface to KeePass database files.
+
+=head1 DESCRIPTION
+
+A command line interface (interactive shell) to work with KeePass
+database files (http://http://en.wikipedia.org/wiki/KeePass). This
+program was inspired by my use of "kedpm -c" combined with my need
+to migrate to KeePass. The curious can read about the Ked Password
+Manager at http://kedpm.sourceforge.net/.
+
+=head1 USAGE
+
+Please run the program and type "help" to learn how to use it.
+
+=head1 PREREQUISITES
+
+This program requires these non-core modules:
+
+C<Crypt::Rijndael> - libcrypt-rijndael-perl on Ubuntu 10.04
+
+C<Term::ReadKey> - libterm-readkey-perl on Ubuntu 10.04
+
+C<Sort::Naturally> - libsort-naturally-perl on Ubuntu 10.04
+
+C<File::KeePass> - libfile-keepass-perl on Ubuntu 12.04
+
+C<Term::ShellUI> - libterm-shellui-perl on Ubuntu 12.10
+
+It is recommended that you install C<Term::ReadLine::Gnu> which will
+provide more fluid signal handling on Unix-like systems, making kpcli
+robust to suspend, resume, and interupt - SIGSTP, SIGCONT and SIGINT.
+That module is in the libterm-readline-gnu-perl package on Ubuntu.
+
+You can optionally install C<Clipboard> and C<Tiny::Capture> to use the
+clipboard features; http://search.cpan.org/~king/Clipboard/ and
+libcapture-tiny-perl on Ubuntu 10.04.
+
+You can optionally install C<Data::Password> to use the pwck feature
+(Password Quality Check); libdata-password-perl on Ubuntu 10.04.
+
+On MS Windows, you can optionally install C<Win32::Console::ANSI> to get
+ANSI colors in Windows cmd terminals. Strawberry Perl 5.16.2 was used
+for the kpcli port to Windows and, using cpanminus, one can install all
+of kpcli's dependencies, sans Term::ReadLine::Gnu which is optional for
+kpcli and not supported on MS Windows.
+
+=head1 CAVEATS AND WORDS OF CAUTION
+
+The main author of kpcli primarily interoperability tests with KeePassX
+(http://www.keepassx.org/) and primarily uses KeePass v1 (*.kdb) files.
+Support for KeePass v2 (*.kdbx) files in kpcli is substantial, and many
+people use it daily, but it is not the author's primary use case. It is
+also the author's intent to maintain compatibility with v1 files, and so
+anyone sending patches, for consideration for inclusion in future kpcli
+versions, is asked to validate them with both v1 and v2 files.
+
+=head2 No history tracking for KeePass 2 (*.kdbx) files
+
+Recording entries' history in KeePass 2 files is not implemented. History
+that exists in a file is not destroyed, but results of entry changes made
+in kpcli are not recorded into their history. Prior-to-change copies are
+stored into the "Recycle Bin." Note that File::KeePass does not encrypt
+passwords of history entries in RAM, like it does for current entries.
+This is a small security risk that can, in theory, allow priviledged users
+to steal your passwords from RAM, from entry history.
+
+=head2 File::KeePass bug prior to version 2.03
+
+Prior to version 2.03, File::KeePass had a bug related to some "unknown"
+data that KeePassX stores in group records. For File::KeePass < v2.03,
+kpcli deletes those unknown data when saving. Research in the libkpass
+(http://libkpass.sourceforge.net/) source code revealed that what early
+versions of File::KeePass classifies as "unknown" are the times for
+created/modified/accessed/expires as well as "flags" (id=9), but only for
+groups; File::KeePass handled those fields just fine for entries. I found
+no ill-effect from dropping those fields when saving and so that is what
+kpcli does to work around the File::KeePass bug, if kpcli is using
+File::KeePass < v2.03.
+
+=head1 BUGS
+
+=head2 Using Ctrl-D to Exit
+
+Versions of Term::ShellUI prior to v0.9. do not have the ability to trap
+Ctrl-D exits by the client program. I submitted a patch to remedy that and
+it made it into Term::ShellUI v0.9. Please upgrade if kpcli asks you to.
+
+=head2 Multiple Entries or Groups With the Same Name in the Same Group
+
+This program does not support multiple entries in the same group having
+the exact same name, nor does it support multiple groups at the same
+level having the same name, and it likely never will. KeepassX does
+support those. This program detects and alert when an opened database
+file has those issues, but it does not refuse to save (overwrite) a file
+that is opened like that. Saves are actually safe (no data loss) as long
+as the user has not touched one of the duplicately-named items.
+
+=head1 AUTHOR
+
+Lester Hightower <hightowe at cpan dot org>
+
+=head1 LICENSE
+
+This program may be distributed under the same terms as Perl itself.
+
+=head1 CREDITS
+
+Special thanks to Paul Seamons, author of C<File::KeePass>, and to
+Scott Bronson, author of C<Term::ShellUI>. Without those two modules
+this program would not have been practical for me to author.
+
+=head1 CHANGELOG
+
+ 2010-Nov-28 v0.1 - Initial release.
+ 2010-Nov-28 v0.2 - Encrypt the master password in RAM.
+ 2010-Nov-29 v0.3 - Fixed master password encryption for saveas.
+ 2010-Nov-29 v0.4 - Fixed code to work w/out Term::ReadLine::Gnu.
+ Documented File::KeePass v0.1 hierarchy bug.
+ 2010-Nov-29 v0.5 - Made find command case insensitive.
+ Bugfix in new command (path regex problem).
+ 2010-Nov-29 v0.6 - Added lock file support; warn if a lock exists.
+ 2010-Dec-01 v0.7 - Further documented the group fields that are
+ dropped, in the CAVEATS section of the POD.
+ Sort group and entry titles naturally.
+ 2010-Dec-23 v0.8 - Worked with File::KeePass author to fix a couple
+ of bugs and then required >=v0.03 of that module.
+ Sorted "/_found" to last in the root group list.
+ Fixed a "database changed" state bug in cli_save().
+ Made the find command ignore entries in /Backup/.
+ Find now offers show when only one entry is found.
+ Provided a patch to Term::ShellUI author to add
+ eof_exit_hook and added support for it to kpcli.
+ 2011-Feb-19 v0.9 - Fixed bugs related to spaces in group names as
+ reported in SourceForge bug number 3132258.
+ The edit command now prompts to save on changes.
+ Put scrub_unknown_values_from_all_groups() calls
+ back into place after realizing that v0.03 of
+ File::KeePass did not resolve all of the problems.
+ 2011-Apr-23 v1.0 - Changed a perl 5.10+ regex to a backward-compatable
+ one to resolve SourceForge bug number 3192413.
+ Modified the way that the /Backup group is ignored
+ by the find command to stop kpcli from croaking on
+ multiple entries with the same name in that group.
+ - Note: There is a more general bug here that
+ needs addressing (see BUGS section).
+ An empty title on new entry aborts the new entry.
+ Changed kdb files are now detected/warned about.
+ Tested against Term::ShellUI v0.9, which has my EOF
+ hook patch, and updated kpcli comments about it.
+ Term::ShellUI's complete_history() method was
+ removed between v0.86 and v0.9 and so I removed
+ kpli's call to it (Ctrl-r works for history).
+ Added the "icons" command.
+ 2011-Sep-07 v1.1 - Empty DBs are now initialized to KeePassX style.
+ Fixed a couple of bugs in the find command.
+ Fixed a password noecho bug in the saveas command.
+ Fixed a kdb_has_changed bug in the saveas command.
+ Fixed a cli_open bug where it wasn't cli_close'ing.
+ Fixed variable init bugs in put_master_passwd().
+ Fixed a false warning in warn_if_file_changed().
+ 2011-Sep-30 v1.2 - Added the "export" command.
+ Added the "import" command.
+ Command "rmdir" asks then deletes non-empty groups.
+ Command "new" can auto-generate random passwords.
+ 2012-Mar-03 v1.3 - Fixed bug in cl command as reported in SourceForge
+ bug number 3496544.
+ 2012-Apr-17 v1.4 - Added key file support based on a user contributed
+ patch with SourceForge ID# 3518388.
+ Added my_help_call() to allow for longer and more
+ descriptive command summaries (for help command).
+ Stopped allowing empty passwords for export.
+ 2012-Oct-13 v1.5 - Fixed "help <foo>" commands, that I broke in v1.4.
+ Command "edit" can auto-generate random passwords.
+ Added the "cls" and "clear" commands from a patch
+ with SourceForge ID# 3573930.
+ Tested compatibility with File::KeePass v2.03 and
+ made minor changes that are possible with >=2.01.
+ With File::KeePass v2.03, kpcli should now support
+ KeePass v2 files (*.kdbx).
+ 2012-Nov-25 v1.6 - Hide passwords (red on red) in the show command
+ unless the -f option is given.
+ Added the --readonly command line option.
+ Added support for multi-line notes/comments;
+ input ends on a line holding a single ".".
+ 2013-Apr-25 v1.7 - Patched to use native File::KeePass support for key
+ files, if the File::KeePass version is new enough.
+ Added the "version" and "ver" commands.
+ Updated documentation as Ubuntu 12.10 now packages
+ all of kpcli's dependencies.
+ Added --histfile command line option.
+ Record modified times on edited records, from a
+ patch with SourceForge ID# 3611713.
+ Added the -a option to the show command.
+ 2013-Jun-09 v2.0 - Removed the unused Clone module after a report that
+ Clone is no longer in core Perl as of v5.18.0.
+ Added the stats and pwck commands.
+ Added clipboard commands (xw/xu/xp/xx).
+ Fixed some long-standing tab completion bugs.
+ Warn if multiple groups or entries are titled the
+ same within a group, except for /Backup entries.
+ 2013-Jun-10 v2.1 - Fixed several more tab completion bugs, and they
+ were serious enough to warrant a quick release.
+ 2013-Jun-16 v2.2 - Trap and handle SIGINT (^C presses).
+ Trap and handle SIGTSTP (^Z presses).
+ Trap and handle SIGCONT (continues after ^Z).
+ Stopped printing found dictionary words in pwck.
+ 2013-Jul-01 v2.3 - More readline() and signal handling improvements.
+ Title conflict checks in cli_new()/edit()/mv().
+ Group title conflict checks in rename().
+ cli_new() now accepts optional path&|title param.
+ cli_ls() can now list multiple paths.
+ cli_edit() now shows the "old" values for users
+ to edit, if Term::ReadLine::Gnu is available.
+ cli_edit() now aborts all changes on ^C.
+ cli_saveas() now asks before overwriting a file.
+ 2013-Nov-26 v2.4 - Fixed several "perl -cw" warnings reported on
+ 2013-07-09 as SourceForge bug #9.
+ Bug fix for the cl command, but in sub cli_ls().
+ First pass at Strawberry perl/MS Windows support.
+ - Enhanced support for Term::ReadLine::Perl
+ - Added support for Term::ReadLine::Perl5
+ Added display of expire time for show -a.
+ Added -a option to the find command.
+ Used the new magic_file_type() in a few places.
+ Added generatePasswordFromDict() and "w" generation.
+ Added the -v option to the version command.
+ - Added the versions command.
+ 2014-Mar-15 v2.5 - Added length control (gNN) to password generation.
+ Added the copy command (and cp alias).
+ Added the clone command.
+ Added optional modules not installed to version -v.
+ Groups can now also be moved with the mv command.
+ Modified cli_cls() to also work on MS Windows.
+ Suppressed Term::ReadLine::Gnu hint on MS Windows.
+ Suppressed missing termcap warning on MS Windows.
+ Print a min number of *s to not leak passwd length.
+ Removed unneeded use of Term::ReadLine.
+ Quieted "inherited AUTOLOAD for non-method" warns
+ caused by Term::Readline::Gnu on perl 5.14.x.
+ 2014-Jun-06 v2.6 - Added interactive password generation ("i" method).
+ - Thanks to Florian Tham for the idea and patch.
+ Show entry's tags if present (KeePass >= v2.11).
+ - Thanks to Florian Tham for the patch.
+ Add/edit support for tags if a v2 file is opened.
+ Added tags to the searched fields for "find -a".
+ Show string fields (key/val pairs) in v2 files.
+ Add/edit for string fields if a v2 file is opened.
+ Show information about entries' file attachments.
+ 2014-03-20 SourceForge feature request #6.
+ New "attach" command to manage file attachments.
+ Added "Recycle Bin" functionality and --no-recycle.
+ For --readonly, don't create a lock file and don't
+ warn if one exists. 2014-03-27 SourceForge bug #11.
+ Added key file generation to saveas and export.
+ 2014-04-19 SourceForge bug #13.
+ Added -expired option to the find command.
+ Added "dir" as an alias for "ls"
+ Added some additional info to the stats command.
+ Added more detailed OS info for Linux/Win in vers.
+ Now hides Meta-Info/SYSTEM entries.
+ Fixed bug with SIGTSTP handling (^Z presses).
+ Fixed missing refresh_state_all_paths() in cli_rm.
+ 2014-Jun-11 v2.7 - Bug fix release. Broke the open command in 2.6.
+ 2015-Feb-08 v2.8 - Fixed cli_copy bug; refresh paths and ask to save.
+ Fixed a cli_mv bug; double path-normalization.
+ Fixed a path display bug, if done after a cli_mv.
+ Protect users from editing in the $FOUND_DIR.
+ Keep file opened, read-only, to show up in lsof.
+ Added inactivity locking (--timeout parameter).
+ Added shell expansion support to cli_ls, with the
+ ability to manage _all_ listed entries by number.
+ Added shell expansion support to cli_mv.
+ Added [y/N] option to list entries after a find.
+
+=head1 TODO ITEMS
+
+ Consider broadening shell_expansion support beyond just mv and ls.
+
+ Consider adding a purge command for "Backup"/"Recycle Bin" folders.
+
+ Consider adding a tags command for use with v2 files.
+ - To navigate by entry tags
+
+ Consider supporting KeePass 2.x style entry history.
+ - There are potential security implications in File::KeePass.
+ - Related, consider adding a purge command for that history.
+
+ Consider adding KeePass 2.x style multi-user synchronization.
+
+ Consider http://search.cpan.org/~sherwin/Data-Password-passwdqc/
+ for password quality checking.
+
+ Consider adding searches for created, modified, and accessed times
+ older than a user supplied time.
+
+ Consider adding import support for Password Safe v3 files using
+ http://search.cpan.org/~tlinden/Crypt-PWSafe3/. May have already
+ done this if the unpackaged dependencies list was not so long.
+
+=head1 OPERATING SYSTEMS AND SCRIPT CATEGORIZATION
+
+=pod OSNAMES
+
+Unix-like
+ - Originally written and tested on Ubuntu Linux 10.04.1 LTS.
+ - As of version 2.5, development is done on Linux Mint 16.
+ - Known to work on many other Linux and *BSD distributions, and
+ kpcli is packaged with many distributions now-a-days.
+
+Microsoft Windows
+ - As of v2.4, Microsoft Windows is also supported.
+ - Tested and compiled on Strawberry Perl 5.16.2 on Windows 7.
+
+=pod SCRIPT CATEGORIES
+
+C<UNIX/System_administration>, C<Win32/Utilities>
+
--
cgit v0.10.2
http://pkgs.fedoraproject.org/cgit/kpcli.git/commit/?h=master&id=fb2dfc513f535b4995713b378170d7bbea379718
More information about the scm-commits
mailing list