summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xbin/raps2325
-rw-r--r--lib/App/Raps2.pm193
-rw-r--r--lib/App/Raps2/Password.pm98
-rw-r--r--lib/App/Raps2/UI.pm94
-rw-r--r--t/20-app-raps2-password.t58
-rw-r--r--t/29-app-raps2.t11
6 files changed, 461 insertions, 318 deletions
diff --git a/bin/raps2 b/bin/raps2
index 763b1d8..c810fec 100755
--- a/bin/raps2
+++ b/bin/raps2
@@ -7,329 +7,18 @@ use warnings;
use 5.010;
use autodie;
-use Crypt::CBC;
-use Crypt::Eksblowfish;
-use Crypt::Eksblowfish::Bcrypt qw(bcrypt_hash en_base64 de_base64);
-use File::Path qw(make_path);
-use File::Slurp qw(slurp write_file);
-use POSIX;
-
-my $VERSION = '0.1';
-
-my %state = (
- cost => 12,
- salt => new_salt(),
-);
+use App::Raps2;
+my $raps2 = App::Raps2->new();
my ($action, @args) = @ARGV;
-sub cmd_add {
- my ($name) = @_;
- my $init = get_xdg_config_file('init');
- my $store = get_xdg_data_file($name);
- my $pass;
-
- if (-e $store) {
- die("This password name already exists\n");
- }
-
- my $cipher;
- my $password;
-
- $password = get_password();
- undef %state;
- load_state_from($init);
- $state{'salt'} = new_salt();
- $state{'cost'} //= 12;
-
- $state{'url'} = read_input(
- prefix => 'URL'
- );
- $state{'login'} = read_input(
- prefix => 'Login'
- );
- $state{'extra'} = read_input(
- prefix => 'Extra'
- );
- $pass = read_input(
- prefix => 'Password',
- invisible => 1,
- verify => 1,
- );
-
- $cipher = setup_cipher($password);
-
- $state{'hash'} = $cipher->encrypt_hex($pass);
-
- save_state_to($store);
-
- return;
-}
-
-sub cmd_dump {
- my ($name) = @_;
- my $store = get_xdg_data_file($name);
- my $password;
- my $cipher;
-
- if (not -e $store) {
- die("No such password\n");
- }
-
- $password = get_password();
- load_state_from($store);
- $cipher = setup_cipher($password);
-
- printf(
- "%-8s : %s\n" x 4,
- 'URL' , $state{'url'},
- 'Login' , $state{'login'},
- 'Extra' , $state{'extra'} // q{},
- 'Password', $cipher->decrypt_hex($state{'hash'}),
- );
-
- return;
-}
-
-sub cmd_get {
- my ($name) = @_;
- my $store = get_xdg_data_file($name);
- my $password;
- my $cipher;
-
- if (not -e $store) {
- die("No such password\n");
- }
-
- $password = get_password();
- load_state_from($store);
-
- $cipher = setup_cipher($password);
-
- to_clipboard($cipher->decrypt_hex($state{'hash'}));
-
- return;
-}
-
-sub cmd_info {
- my ($name) = @_;
- my $store = get_xdg_data_file($name);
-
- if (not -e $store) {
- die("No such password\n");
- }
-
- load_state_from($store);
-
- printf(
- "%-8s : %s\n" x 3,
- 'URL' , $state{'url'},
- 'Login', $state{'login'},
- 'Extra', $state{'extra'} // q{},
- );
- return;
-}
-
-sub cmd_list {
- opendir(my $dh, get_xdg_data_file());
- my @entries = grep { /^[^.]/ } readdir($dh);
- closedir($dh);
-
- for my $file (sort @entries) {
- say $file;
- }
-}
-
-sub create_dot_dirs {
- make_path(get_xdg_config_file());
- make_path(get_xdg_data_file());
- return;
-}
-
-sub create_pass {
- my ($passfile) = @_;
- my $pass;
- my $hash;
-
- say 'raps2 was never run before. Please set master password first.';
- $pass = read_input(
- prefix => 'Password',
- invisible => 1,
- verify => 1,
- );
-
- $hash = en_base64(bcrypt_hash({
- key_nul => 1,
- cost => $state{'cost'},
- salt => $state{'salt'},
- }, $pass));
- write_file(
- $passfile,
- "cost $state{cost}\n",
- "salt $state{salt}\n",
- "hash $hash\n",
- );
-
- return $pass;
-}
-
-sub get_password {
- my $pass;
- my $passfile = get_xdg_config_file('password');
-
- if (not -e $passfile) {
- return create_pass($passfile);
- }
-
- load_state_from($passfile);
- $pass = read_input(
- prefix => 'Master password',
- invisible => 1,
- );
- if (en_base64(bcrypt_hash({
- key_nul => 1,
- cost => $state{'cost'},
- salt => $state{'salt'},
- }, $pass)) ne $state{'hash'}) {
- die("Invalid passphrase\n");
- }
-
- return $pass;
-}
-
-sub get_xdg_config_file {
- my ($file) = @_;
- my $env = $ENV{'XDG_CONFIG_HOME'};
- my $home = $ENV{'HOME'};
-
- $file //= q{};
- $env //= "${home}/.config";
-
- return "${env}/raps2/${file}";
-}
-
-sub get_xdg_data_file {
- my ($file) = @_;
- my $env = $ENV{'XDG_DATA_HOME'};
- my $home = $ENV{'HOME'};
-
- $file //= q{};
- $env //= "${home}/.local/share";
-
- return "${env}/raps2/${file}";
-}
-
-sub load_state_from {
- my ($file) = @_;
-
- if (not -e $file) {
- return;
- }
-
- for my $line (slurp($file)) {
- my ($key, $value) = split(qr{\s+}, $line);
-
- if (not ($key and $value)) {
- next;
- }
-
- $state{$key} = $value;
- }
- return;
-}
-
-sub new_salt {
- my $salt = q{};
-
- for (1 .. 16) {
- $salt .= chr(0x21 + int(rand(90)));
- }
-
- return $salt;
-}
-
-sub save_state_to {
- my ($file) = @_;
- my $raw = q{};
-
- while (my ($key, $value) = each(%state)) {
- $raw .= "${key} ${value}\n";
- }
-
- write_file($file, $raw);
- return;
-}
-
-sub setup_cipher {
- my ($password) = @_;
-
- my $eksblowfish = Crypt::Eksblowfish->new(
- $state{'cost'},
- $state{'salt'},
- $password,
- );
- return Crypt::CBC->new(-cipher => $eksblowfish);
-}
-
-sub to_clipboard {
- my ($pw) = @_;
-
- open(my $clipboard, '|-', 'xclip -l 1');
- print $clipboard $pw;
- close($clipboard);
- return;
-}
-
-sub read_input {
- my %opts = @_;
- my ($prefix, $invisible, $verify)
- = @opts{'prefix', 'invisible', 'verify'};
- my $term = POSIX::Termios->new();
- my ($input1, $input2);
-
- if ($invisible) {
- $term->getattr(0);
- $term->setlflag($term->getlflag() & ~POSIX::ECHO);
- $term->setattr(0, POSIX::TCSANOW);
- }
-
- print "${prefix}: ";
- $input1 = readline(STDIN);
-
- if ($invisible) {
- print "\n";
- }
-
- if ($verify) {
- print 'Verify: ';
- $input2 = readline(STDIN);
-
- if ($invisible) {
- print "\n";
- }
- }
-
- if ($invisible) {
- $term->setlflag($term->getlflag() | POSIX::ECHO);
- $term->setattr(0, POSIX::TCSANOW);
- }
-
- if ($verify and $input1 ne $input2) {
- die("Lines do not match\n");
- }
-
- chomp $input1;
- return $input1;
-}
-
-create_dot_dirs();
+$raps2->sanity_check();
+$raps2->load_config();
given ($action) {
- when ('add') { cmd_add(@args) }
- when ('dump') { cmd_dump(@args) }
- when ('get') { cmd_get(@args) }
- when ('info') { cmd_info(@args) }
- when ('list') { cmd_list(@args) }
+ when ('add') { $raps2->cmd_add(@args) }
+ when ('dump') { $raps2->cmd_dump(@args) }
+ when ('info') { $raps2->cmd_info(@args) }
}
__END__
diff --git a/lib/App/Raps2.pm b/lib/App/Raps2.pm
new file mode 100644
index 0000000..4746e5a
--- /dev/null
+++ b/lib/App/Raps2.pm
@@ -0,0 +1,193 @@
+package App::Raps2;
+
+
+
+
+use strict;
+use warnings;
+use autodie;
+use 5.010;
+
+use base 'Exporter';
+
+use App::Raps2::Password;
+use App::Raps2::UI;
+use File::Path qw(make_path);
+use File::Slurp qw(slurp write_file);
+
+our @EXPORT_OK = ();
+our $VERSION = '0.1';
+
+sub create_salt {
+ my $salt = q{};
+
+ for (1 .. 16) {
+ $salt .= chr(0x21 + int(rand(90)));
+ }
+
+ return $salt;
+}
+
+sub file_to_hash {
+ my ($file) = @_;
+ my %ret;
+
+ for my $line (slurp($file)) {
+ my ($key, $value) = split(qr{\s+}, $line);
+
+ if (not ($key and $value)) {
+ next;
+ }
+
+ $ret{$key} = $value;
+ }
+ return %ret;
+}
+
+sub new {
+ my ($obj, %conf) = @_;
+ my $ref = {};
+
+ $ref->{'xdg_conf'} = $ENV{'XDG_CONFIG_HOME'} // "$ENV{HOME}/.config/raps2";
+ $ref->{'xdg_data'} = $ENV{'XDG_DATA_HOME'} //
+ "$ENV{HOME}/.local/share/raps2";
+
+ $ref->{'ui'} = App::Raps2::UI->new();
+
+ $ref->{'default'} = \%conf;
+
+ return bless($ref, $obj);
+}
+
+sub sanity_check {
+ my ($self) = @_;
+
+ make_path($self->{'xdg_conf'});
+ make_path($self->{'xdg_data'});
+
+ if (not -e $self->{'xdg_conf'} . '/password') {
+ $self->create_config();
+ }
+
+ return;
+}
+
+sub get_master_password {
+ my ($self) = @_;
+ my $pass = $self->{'ui'}->read_pw('Master Password', 0);
+
+ $self->{'pass'} = App::Raps2::Password->new(
+ cost => $self->{'default'}->{'cost'},
+ salt => $self->{'master_salt'},
+ passphrase => $pass,
+ );
+
+ if (not $self->{'pass'}->verify($self->{'master_hash'})) {
+ return undef;
+ }
+}
+
+sub create_config {
+ my ($self) = @_;
+ my $cost = 12;
+ my $salt = create_salt();
+ my $pass = $self->{'ui'}->read_pw('Master Password', 1);
+
+ $self->{'pass'} = App::Raps2::Password->new(
+ cost => $cost,
+ salt => $salt,
+ passphrase => $pass,
+ );
+ my $hash = $self->{'pass'}->crypt();
+
+ write_file(
+ $self->{'xdg_conf'} . '/password',
+ "cost ${cost}\n",
+ "salt ${salt}\n",
+ "hash ${hash}\n",
+ );
+}
+
+sub load_config {
+ my ($self) = @_;
+ my %cfg = file_to_hash($self->{'xdg_conf'} . '/password');
+ $self->{'master_hash'} = $cfg{'hash'};
+ $self->{'master_salt'} = $cfg{'salt'};
+ $self->{'default'}->{'cost'} //= $cfg{'cost'};
+}
+
+sub cmd_add {
+ my ($self, $name) = @_;
+ my $pwfile = $self->{'xdg_data'} . "/${name}";
+ my $ui = $self->{'ui'};
+
+ if (-e $pwfile) {
+ return undef;
+ }
+
+ $self->get_master_password();
+
+ my $salt = create_salt();
+ my $url = $ui->read_line('URL');
+ my $login = $ui->read_line('Login');
+ my $pass = $ui->read_pw('Password', 1);
+ my $extra = $ui->read_multiline('Additional content');
+
+ $self->{'pass'}->salt($salt);
+ my $pass_hash = $self->{'pass'}->encrypt($pass);
+ my $extra_hash = (
+ $extra ?
+ $self->{'pass'}->encrypt($extra) :
+ q{}
+ );
+
+
+ write_file(
+ $pwfile,
+ "url ${url}\n",
+ "login ${login}\n",
+ "salt ${salt}\n",
+ "hash ${pass_hash}\n",
+ "extra ${extra_hash}\n",
+ );
+}
+
+sub cmd_dump {
+ my ($self, $name) = @_;
+ my $pwfile = $self->{'xdg_data'} . "/${name}";
+
+ if (not -e $pwfile) {
+ return undef;
+ }
+
+ my %key = file_to_hash($pwfile);
+
+ $self->get_master_password();
+
+ $self->{'pass'}->salt($key{'salt'});
+
+ $self->{'ui'}->output(
+ ['URL', $key{'url'}],
+ ['Login', $key{'login'}],
+ ['Password', $self->{'pass'}->decrypt($key{'hash'})],
+ );
+ if ($key{'extra'}) {
+ say $self->{'pass'}->decrypt($key{'extra'});
+ }
+}
+
+
+sub cmd_info {
+ my ($self, $name) = @_;
+ my $pwfile = $self->{'xdg_data'} . "/${name}";
+
+ if (not -e $pwfile) {
+ return undef;
+ }
+
+ my %key = file_to_hash($pwfile);
+ $self->{'ui'}->output(
+ ['URL', $key{'url'}],
+ ['Login', $key{'login'}],
+ );
+}
diff --git a/lib/App/Raps2/Password.pm b/lib/App/Raps2/Password.pm
new file mode 100644
index 0000000..2ac1a51
--- /dev/null
+++ b/lib/App/Raps2/Password.pm
@@ -0,0 +1,98 @@
+package App::Raps2::Password;
+
+
+
+
+use strict;
+use warnings;
+use autodie;
+use 5.010;
+
+use base 'Exporter';
+
+use Crypt::CBC;
+use Crypt::Eksblowfish;
+use Crypt::Eksblowfish::Bcrypt qw(bcrypt_hash en_base64 de_base64);
+
+our @EXPORT_OK = ();
+our $VERSION = '0.1';
+
+sub new {
+ my ($obj, %conf) = @_;
+
+ $conf{'cost'} //= 12;
+
+ if (not (defined $conf{'salt'} and length($conf{'salt'}) == 16)) {
+ return undef;
+ }
+
+ if (not (defined $conf{'passphrase'} and length $conf{'passphrase'})) {
+ return undef;
+ }
+
+ my $ref = \%conf;
+
+ return bless($ref, $obj);
+}
+
+sub salt {
+ my ($self, $salt) = @_;
+
+ if (not (defined $salt and length($salt) == 16)) {
+ return undef;
+ }
+
+ $self->{'salt'} = $salt;
+}
+
+sub encrypt {
+ my ($self, $in) = @_;
+
+ my $eksblowfish = Crypt::Eksblowfish->new(
+ $self->{'cost'},
+ $self->{'salt'},
+ $self->{'passphrase'},
+ );
+ my $cbc = Crypt::CBC->new(-cipher => $eksblowfish);
+
+ return $cbc->encrypt_hex($in);
+}
+
+sub decrypt {
+ my ($self, $in) = @_;
+
+ my $eksblowfish = Crypt::Eksblowfish->new(
+ $self->{'cost'},
+ $self->{'salt'},
+ $self->{'passphrase'},
+ );
+ my $cbc = Crypt::CBC->new(-cipher => $eksblowfish);
+
+ return $cbc->decrypt_hex($in);
+}
+
+sub crypt {
+ my ($self) = @_;
+
+ return en_base64(
+ bcrypt_hash({
+ key_nul => 1,
+ cost => $self->{'cost'},
+ salt => $self->{'salt'},
+ },
+ $self->{'passphrase'},
+ ));
+}
+
+sub verify {
+ my ($self, $testhash) = @_;
+
+ my $myhash = $self->crypt();
+
+ if ($testhash eq $myhash) {
+ return 1;
+ }
+ return undef;
+}
+
+1;
diff --git a/lib/App/Raps2/UI.pm b/lib/App/Raps2/UI.pm
new file mode 100644
index 0000000..43abf29
--- /dev/null
+++ b/lib/App/Raps2/UI.pm
@@ -0,0 +1,94 @@
+package App::Raps2::UI;
+
+use strict;
+use warnings;
+use autodie;
+use 5.010;
+
+use base 'Exporter';
+
+use POSIX;
+
+our @EXPORT_OK = ();
+our $VERSION = '0.1';
+
+sub new {
+ my ($obj) = @_;
+ my $ref = {};
+ return bless($ref, $obj);
+}
+
+sub read_line {
+ my ($self, $str) = @_;
+
+ print "${str}: ";
+ my $input = readline(STDIN);
+
+ chomp $input;
+ return $input;
+}
+
+sub read_multiline {
+ my ($self, $str) = @_;
+ my $in;
+
+ say "${str} (^D to quit)";
+
+ while (my $line = <STDIN>) {
+ $in .= $line;
+ }
+ return $in;
+}
+
+sub read_pw {
+ my ($self, $str, $verify) = @_;
+ my ($in1, $in2);
+
+ my $term = POSIX::Termios->new();
+ $term->getattr(0);
+ $term->setlflag($term->getlflag() & ~POSIX::ECHO);
+ $term->setattr(0, POSIX::TCSANOW);
+
+ print "${str}: ";
+ $in1 = readline(STDIN);
+ print "\n";
+
+ if ($verify) {
+ print 'Verify: ';
+ $in2 = readline(STDIN);
+ print "\n";
+ }
+
+ $term->setlflag($term->getlflag() | POSIX::ECHO);
+ $term->setattr(0, POSIX::TCSANOW);
+
+ if ($verify and $in1 ne $in2) {
+ return undef;
+ }
+
+ chomp $in1;
+ return $in1;
+}
+
+sub to_clipboard {
+ my ($self, $str) = @_;
+
+ open(my $clipboard, '|-', 'xclip -l 1');
+ print $clipboard $str;
+ close($clipboard);
+ return;
+}
+
+sub output {
+ my ($self, @out) = @_;
+
+ for my $pair (@out) {
+ printf(
+ "%-8s : %s\n",
+ @{$pair},
+ );
+ }
+ return;
+}
+
+1;
diff --git a/t/20-app-raps2-password.t b/t/20-app-raps2-password.t
new file mode 100644
index 0000000..7e36358
--- /dev/null
+++ b/t/20-app-raps2-password.t
@@ -0,0 +1,58 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+use 5.010;
+
+use Test::More tests => 13;
+
+my $pw;
+my $salt = 'abcdefghijklmnop';
+my $pass = 'something';
+
+use_ok('App::Raps2::Password');
+
+$pw = App::Raps2::Password->new();
+is($pw, undef, 'new() missing salt and passphrase');
+
+$pw = App::Raps2::Password->new(salt => $salt);
+is($pw, undef, 'new() missing passphrase');
+
+$pw = App::Raps2::Password->new(passphrase => $pass);
+is($pw, undef, 'new() missing salt');
+
+$pw = App::Raps2::Password->new(
+ passphrase => $pass,
+ salt => 'abcdefghijklmno',
+);
+is($pw, undef, 'new() salt one too short');
+
+$pw = App::Raps2::Password->new(
+ passphrase => $pass,
+ salt => $salt . 'z',
+);
+is($pw, undef, 'new() salt one too long');
+
+$pw = App::Raps2::Password->new(
+ passphrase => $pass,
+ salt => $salt,
+);
+isa_ok($pw, 'App::Raps2::Password');
+
+$pw = App::Raps2::Password->new(
+ cost => 8,
+ salt => $salt,
+ passphrase => $pass,
+);
+
+isa_ok($pw, 'App::Raps2::Password');
+
+is($pw->decrypt('53616c7465645f5f80d8c367e15980d43ec9a6eabc5390b4'), 'quux',
+ 'decrypt okay');
+
+is($pw->decrypt($pw->encrypt('foo')), 'foo', 'encrypt->decrypt okay');
+
+ok($pw->verify('3lJRlaRuOGWv/z3g1DAOlcH.u9vS8Wm'), 'verify: verifies correct hash');
+
+ok(!$pw->verify('3lJRlaRuOGWv/z3g1DAOlcH.u9vS8WM'), 'verify: does not verify invalid hash');
+
+ok($pw->verify($pw->crypt('truth')), 'crypt->verify okay')
diff --git a/t/29-app-raps2.t b/t/29-app-raps2.t
new file mode 100644
index 0000000..28fe4fc
--- /dev/null
+++ b/t/29-app-raps2.t
@@ -0,0 +1,11 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+use 5.010;
+
+use Test::More tests => 2;
+
+use_ok('App::Raps2');
+
+my $r2 = App::Raps2->new();
+isa_ok($r2, 'App::Raps2');