]> Dogcows Code - chaz/p5-File-KDBX/blobdiff - lib/File/KDBX/Dumper/V3.pm
add initial WIP
[chaz/p5-File-KDBX] / lib / File / KDBX / Dumper / V3.pm
diff --git a/lib/File/KDBX/Dumper/V3.pm b/lib/File/KDBX/Dumper/V3.pm
new file mode 100644 (file)
index 0000000..890af02
--- /dev/null
@@ -0,0 +1,177 @@
+package File::KDBX::Dumper::V3;
+# ABSTRACT: Dump KDBX3 files
+
+use warnings;
+use strict;
+
+use Crypt::Digest qw(digest_data);
+use Encode qw(encode);
+use File::KDBX::Constants qw(:header :compression);
+use File::KDBX::Error;
+use File::KDBX::Util qw(:empty assert_64bit erase_scoped);
+use IO::Handle;
+use PerlIO::via::File::KDBX::Crypt;
+use PerlIO::via::File::KDBX::HashBlock;
+use namespace::clean;
+
+use parent 'File::KDBX::Dumper';
+
+our $VERSION = '999.999'; # VERSION
+
+sub _write_headers {
+    my $self = shift;
+    my $fh = shift;
+
+    my $kdbx = $self->kdbx;
+    my $headers = $kdbx->headers;
+    my $buf = '';
+
+    # FIXME kinda janky - maybe add a "prepare" hook to massage the KDBX into the correct shape before we get
+    # this far
+    local $headers->{+HEADER_TRANSFORM_SEED} = $kdbx->transform_seed;
+    local $headers->{+HEADER_TRANSFORM_ROUNDS} = $kdbx->transform_rounds;
+
+    if (nonempty (my $comment = $headers->{+HEADER_COMMENT})) {
+        $buf .= $self->_write_header($fh, HEADER_COMMENT, $comment);
+    }
+    for my $type (
+        HEADER_CIPHER_ID,
+        HEADER_COMPRESSION_FLAGS,
+        HEADER_MASTER_SEED,
+        HEADER_TRANSFORM_SEED,
+        HEADER_TRANSFORM_ROUNDS,
+        HEADER_ENCRYPTION_IV,
+        HEADER_INNER_RANDOM_STREAM_KEY,
+        HEADER_STREAM_START_BYTES,
+        HEADER_INNER_RANDOM_STREAM_ID,
+    ) {
+        defined $headers->{$type} or throw "Missing value for required header: $type", type => $type;
+        $buf .= $self->_write_header($fh, $type, $headers->{$type});
+    }
+    $buf .= $self->_write_header($fh, HEADER_END);
+
+    return $buf;
+}
+
+sub _write_header {
+    my $self = shift;
+    my $fh   = shift;
+    my $type = shift;
+    my $val  = shift // '';
+
+    $type = KDBX_HEADER($type);
+    if ($type == HEADER_END) {
+        $val = "\r\n\r\n";
+    }
+    elsif ($type == HEADER_COMMENT) {
+        $val = encode('UTF-8', $val);
+    }
+    elsif ($type == HEADER_CIPHER_ID) {
+        my $size = length($val);
+        $size == 16 or throw 'Invalid cipher UUID length', got => $size, expected => $size;
+    }
+    elsif ($type == HEADER_COMPRESSION_FLAGS) {
+        $val = pack('L<', $val);
+    }
+    elsif ($type == HEADER_MASTER_SEED) {
+        my $size = length($val);
+        $size == 32 or throw 'Invalid master seed length', got => $size, expected => $size;
+    }
+    elsif ($type == HEADER_TRANSFORM_SEED) {
+        # nothing
+    }
+    elsif ($type == HEADER_TRANSFORM_ROUNDS) {
+        assert_64bit;
+        $val = pack('Q<', $val);
+    }
+    elsif ($type == HEADER_ENCRYPTION_IV) {
+        # nothing
+    }
+    elsif ($type == HEADER_INNER_RANDOM_STREAM_KEY) {
+        # nothing
+    }
+    elsif ($type == HEADER_STREAM_START_BYTES) {
+        # nothing
+    }
+    elsif ($type == HEADER_INNER_RANDOM_STREAM_ID) {
+        $val = pack('L<', $val);
+    }
+    elsif ($type == HEADER_KDF_PARAMETERS ||
+           $type == HEADER_PUBLIC_CUSTOM_DATA) {
+        throw "Unexpected KDBX4 header: $type", type => $type;
+    }
+    elsif ($type == HEADER_COMMENT) {
+        throw "Unexpected KDB header: $type", type => $type;
+    }
+    else {
+        alert "Unknown header: $type", type => $type;
+    }
+
+    my $size = length($val);
+    my $buf = pack('C S<', 0+$type, $size);
+
+    $fh->print($buf, $val) or throw 'Failed to write header';
+
+    return "$buf$val";
+}
+
+sub _write_body {
+    my $self = shift;
+    my $fh   = shift;
+    my $key  = shift;
+    my $header_data = shift;
+    my $kdbx = $self->kdbx;
+
+    # assert all required headers present
+    for my $field (
+        HEADER_CIPHER_ID,
+        HEADER_ENCRYPTION_IV,
+        HEADER_MASTER_SEED,
+        HEADER_INNER_RANDOM_STREAM_KEY,
+        HEADER_STREAM_START_BYTES,
+    ) {
+        defined $kdbx->headers->{$field} or throw "Missing $field";
+    }
+
+    my $master_seed = $kdbx->headers->{+HEADER_MASTER_SEED};
+
+    my @cleanup;
+    $key = $kdbx->composite_key($key);
+
+    my $response = $key->challenge($master_seed);
+    push @cleanup, erase_scoped $response;
+
+    my $transformed_key = $kdbx->kdf->transform($key);
+    push @cleanup, erase_scoped $transformed_key;
+
+    my $final_key = digest_data('SHA256', $master_seed, $response, $transformed_key);
+    push @cleanup, erase_scoped $final_key;
+
+    my $cipher = $kdbx->cipher(key => $final_key);
+    PerlIO::via::File::KDBX::Crypt->push($fh, $cipher);
+
+    $fh->print($kdbx->headers->{+HEADER_STREAM_START_BYTES})
+        or throw 'Failed to write start bytes';
+    $fh->flush;
+
+    $kdbx->key($key);
+
+    PerlIO::via::File::KDBX::HashBlock->push($fh);
+
+    my $compress = $kdbx->headers->{+HEADER_COMPRESSION_FLAGS};
+    if ($compress == COMPRESSION_GZIP) {
+        require PerlIO::via::File::KDBX::Compression;
+        PerlIO::via::File::KDBX::Compression->push($fh);
+    }
+    elsif ($compress != COMPRESSION_NONE) {
+        throw "Unsupported compression ($compress)\n", compression_flags => $compress;
+    }
+
+    my $header_hash = digest_data('SHA256', $header_data);
+    $self->_write_inner_body($fh, $header_hash);
+
+    binmode($fh, ':pop') if $compress;
+    binmode($fh, ':pop:pop');
+}
+
+1;
This page took 0.026116 seconds and 4 git commands to generate.