]> Dogcows Code - chaz/p5-File-KDBX/blobdiff - lib/File/KDBX/Safe.pm
add initial WIP
[chaz/p5-File-KDBX] / lib / File / KDBX / Safe.pm
diff --git a/lib/File/KDBX/Safe.pm b/lib/File/KDBX/Safe.pm
new file mode 100644 (file)
index 0000000..24a3cf4
--- /dev/null
@@ -0,0 +1,300 @@
+package File::KDBX::Safe;
+# ABSTRACT: Keep strings encrypted while in memory
+
+use warnings;
+use strict;
+
+use Crypt::PRNG qw(random_bytes);
+use Devel::GlobalDestruction;
+use Encode qw(encode decode);
+use File::KDBX::Constants qw(:random_stream);
+use File::KDBX::Error;
+use File::KDBX::Util qw(erase erase_scoped);
+use Ref::Util qw(is_arrayref is_coderef is_hashref is_scalarref);
+use Scalar::Util qw(refaddr);
+use namespace::clean;
+
+our $VERSION = '999.999'; # VERSION
+
+=method new
+
+    $safe = File::KDBX::Safe->new(%attributes);
+    $safe = File::KDBX::Safe->new(\@strings, %attributes);
+
+Create a new safe for storing secret strings encrypted in memory.
+
+If a cipher is passed, its stream will be reset.
+
+=cut
+
+sub new {
+    my $class = shift;
+    my %args = @_ % 2 == 0 ? @_ : (strings => shift, @_);
+
+    if (!$args{cipher} && $args{key}) {
+        require File::KDBX::Cipher;
+        $args{cipher} = File::KDBX::Cipher->new(stream_id => STREAM_ID_CHACHA20, key => $args{key});
+    }
+
+    my $self = bless \%args, $class;
+    $self->cipher->finish;
+    $self->{counter} = 0;
+
+    my $strings = delete $args{strings};
+    $self->{items} = [];
+    $self->{index} = {};
+    $self->add($strings) if $strings;
+
+    return $self;
+}
+
+sub DESTROY { !in_global_destruction and $_[0]->unlock }
+
+=method clear
+
+    $safe->clear;
+
+Clear a safe, removing all store contents permanently.
+
+=cut
+
+sub clear {
+    my $self = shift;
+    $self->{items} = [];
+    $self->{index} = {};
+    $self->{counter} = 0;
+    return $self;
+}
+
+=method add
+
+    $safe = $safe->lock(@strings);
+    $safe = $safe->lock(\@strings);
+
+Add strings to be encrypted.
+
+Alias: C<lock>
+
+=cut
+
+sub lock { shift->add(@_) }
+
+sub add {
+    my $self    = shift;
+    my @strings = map { is_arrayref($_) ? @$_ : $_ } @_;
+
+    @strings or throw 'Must provide strings to lock';
+
+    my $cipher = $self->cipher;
+
+    for my $string (@strings) {
+        my $item = {str => $string, off => $self->{counter}};
+        if (is_scalarref($string)) {
+            next if !defined $$string;
+            $item->{enc} = 'UTF-8' if utf8::is_utf8($$string);
+            if (my $encoding = $item->{enc}) {
+                my $encoded = encode($encoding, $$string);
+                $item->{val} = $cipher->crypt(\$encoded);
+                erase $encoded;
+            }
+            else {
+                $item->{val} = $cipher->crypt($string);
+            }
+            erase $string;
+        }
+        elsif (is_hashref($string)) {
+            next if !defined $string->{value};
+            $item->{enc} = 'UTF-8' if utf8::is_utf8($string->{value});
+            if (my $encoding = $item->{enc}) {
+                my $encoded = encode($encoding, $string->{value});
+                $item->{val} = $cipher->crypt(\$encoded);
+                erase $encoded;
+            }
+            else {
+                $item->{val} = $cipher->crypt(\$string->{value});
+            }
+            erase \$string->{value};
+        }
+        else {
+            throw 'Safe strings must be a hashref or stringref', type => ref $string;
+        }
+        push @{$self->{items}}, $item;
+        $self->{index}{refaddr($string)} = $item;
+        $self->{counter} += length($item->{val});
+    }
+
+    return $self;
+}
+
+=method add_protected
+
+    $safe = $safe->add_protected(@strings);
+    $safe = $safe->add_protected(\@strings);
+
+Add strings that are already encrypted.
+
+B<WARNING:> You must add already-encrypted strings in the order in which they were original encrypted or they
+will not decrypt correctly. You almost certainly do not want to add both unprotected and protected strings to
+a safe.
+
+=cut
+
+sub add_protected {
+    my $self = shift;
+    my $filter = is_coderef($_[0]) ? shift : undef;
+    my @strings = map { is_arrayref($_) ? @$_ : $_ } @_;
+
+    @strings or throw 'Must provide strings to lock';
+
+    for my $string (@strings) {
+        my $item = {str => $string};
+        $item->{filter} = $filter if defined $filter;
+        if (is_scalarref($string)) {
+            next if !defined $$string;
+            $item->{val} = $$string;
+            erase $string;
+        }
+        elsif (is_hashref($string)) {
+            next if !defined $string->{value};
+            $item->{val} = $string->{value};
+            erase \$string->{value};
+        }
+        else {
+            throw 'Safe strings must be a hashref or stringref', type => ref $string;
+        }
+        push @{$self->{items}}, $item;
+        $self->{index}{refaddr($string)} = $item;
+        $self->{counter} += length($item->{val});
+    }
+
+    return $self;
+}
+
+=method unlock
+
+    $safe = $safe->unlock;
+
+Decrypt all the strings. Each stored string is set to its original value.
+
+This happens automatically when the safe is garbage-collected.
+
+=cut
+
+sub unlock {
+    my $self = shift;
+
+    my $cipher = $self->cipher;
+    $cipher->finish;
+    $self->{counter} = 0;
+
+    for my $item (@{$self->{items}}) {
+        my $string  = $item->{str};
+        my $cleanup = erase_scoped \$item->{val};
+        my $str_ref;
+        if (is_scalarref($string)) {
+            $$string = $cipher->crypt(\$item->{val});
+            if (my $encoding = $item->{enc}) {
+                my $decoded = decode($encoding, $string->{value});
+                erase $string;
+                $$string = $decoded;
+            }
+            $str_ref = $string;
+        }
+        elsif (is_hashref($string)) {
+            $string->{value} = $cipher->crypt(\$item->{val});
+            if (my $encoding = $item->{enc}) {
+                my $decoded = decode($encoding, $string->{value});
+                erase \$string->{value};
+                $string->{value} = $decoded;
+            }
+            $str_ref = \$string->{value};
+        }
+        else {
+            die 'Unexpected';
+        }
+        if (my $filter = $item->{filter}) {
+            my $filtered = $filter->($$str_ref);
+            erase $str_ref;
+            $$str_ref = $filtered;
+        }
+    }
+
+    return $self->clear;
+}
+
+=method peek
+
+    $string_value = $safe->peek($string);
+    ...
+    erase $string_value;
+
+Peek into the safe at a particular string without decrypting the whole safe. A copy of the string is returned,
+and in order to ensure integrity of the memory protection you should erase the copy when you're done.
+
+=cut
+
+sub peek {
+    my $self = shift;
+    my $string = shift;
+
+    my $item = $self->{index}{refaddr($string)} // return;
+
+    my $cipher = $self->cipher->dup(offset => $item->{off});
+
+    my $value = $cipher->crypt(\$item->{val});
+    if (my $encoding = $item->{enc}) {
+        my $decoded = decode($encoding, $value);
+        erase $value;
+        return $decoded;
+    }
+    return $value;
+}
+
+=attr cipher
+
+    $cipher = $safe->cipher;
+
+Get the L<File::KDBX::Cipher::Stream> protecting a safe.
+
+=cut
+
+sub cipher {
+    my $self = shift;
+    $self->{cipher} //= do {
+        require File::KDBX::Cipher;
+        File::KDBX::Cipher->new(stream_id => STREAM_ID_CHACHA20, key => random_bytes(64));
+    };
+}
+
+1;
+__END__
+
+=head1 SYNOPSIS
+
+    use File::KDBX::Safe;
+
+    $safe = File::KDBX::Safe->new;
+
+    my $msg = 'Secret text';
+    $safe->add(\$msg);
+    # $msg is now undef, the original message no longer in RAM
+
+    my $obj = { value => 'Also secret' };
+    $safe->add($obj);
+    # $obj is now { value => undef }
+
+    say $safe->peek($msg);  # Secret text
+
+    $safe->unlock;
+    say $msg;               # Secret text
+    say $obj->{value};      # Also secret
+
+=head1 DESCRIPTION
+
+This module provides memory protection functionality. It keeps strings encrypted in memory and decrypts them
+as-needed. Encryption and decryption is done using a L<File::KDBX::Cipher::Stream>.
+
+A safe can protect one or more (possibly many) strings. When a string is added to a safe, it gets added to an
+internal list so it will be decrypted when the entire safe is unlocked.
+
+=cut
This page took 0.024225 seconds and 4 git commands to generate.