diff options
-rwxr-xr-x | bin/raps2 | 325 | ||||
-rw-r--r-- | lib/App/Raps2.pm | 193 | ||||
-rw-r--r-- | lib/App/Raps2/Password.pm | 98 | ||||
-rw-r--r-- | lib/App/Raps2/UI.pm | 94 | ||||
-rw-r--r-- | t/20-app-raps2-password.t | 58 | ||||
-rw-r--r-- | t/29-app-raps2.t | 11 |
6 files changed, 461 insertions, 318 deletions
@@ -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'); |