Blockchain-Ethereum

 view release on metacpan or  search on metacpan

lib/Blockchain/Ethereum/Keystore/File.pm  view on Meta::CPAN

package Blockchain::Ethereum::Keystore::File;

use v5.26;
use strict;
use warnings;

# ABSTRACT: Ethereum keystore file abstraction
our $AUTHORITY = 'cpan:REFECO';    # AUTHORITY
our $VERSION   = '0.021';          # VERSION

use Carp;
use JSON::MaybeXS;
use Crypt::PRNG;
use Crypt::Mode::CTR;
use Crypt::Digest::Keccak256 qw(keccak256);
use Scalar::Util             qw(blessed);
use Data::UUID;

use Blockchain::Ethereum::Key;
use Blockchain::Ethereum::Keystore::KDF;

my $json = JSON::MaybeXS->new(
    utf8      => 1,
    pretty    => 1,
    canonical => 1
);

sub from_key {
    my ($class, $key) = @_;

    croak 'key must be a Blockchain::Ethereum::Key instance'
        unless blessed $key && $key->isa('Blockchain::Ethereum::Key');

    my $self = bless {private_key => $key}, $class;
    return $self;
}

sub from_file {
    my ($class, $file_path, $password) = @_;

    my $self = bless {}, $class;

    my $content;
    {
        open my $fh, '<:raw', $file_path
            or croak "Could not read file '$file_path': $!";
        local $/;    # Enable slurp mode
        $content = <$fh>;
        close $fh;
    }
    my $decoded = $json->decode(lc $content);

    croak 'Version not supported'                        unless $decoded->{version} && $decoded->{version} == 3;
    croak 'Password is required to decrypt the keystore' unless defined $password;
    $self->{password} = $password;

    return $self->_from_v3($decoded);
}

sub cipher {
    shift->{cipher} //= Crypt::Mode::CTR->new('AES', 1);
}

sub ciphertext {
    my $self = shift;
    $self->{ciphertext} //= $self->_generate_ciphertext;
}

sub mac {
    my $self = shift;
    $self->{mac} //= $self->_generate_mac;

}

sub version {
    shift->{version} //= 3;
}

sub iv {
    my $self = shift;
    $self->{iv} //= $self->_generate_random_iv;
}

sub kdf {
    my $self = shift;
    $self->{kdf} //= $self->_generate_kdf;
}

sub id {
    my $self = shift;
    $self->{id} //= $self->_generate_id;
}

sub private_key {
    shift->{private_key};
}

sub password {
    shift->{password};
}

sub _from_v3 {
    my ($self, $object) = @_;

    my $crypto = $object->{crypto};

    $self->{ciphertext} = $crypto->{ciphertext};
    $self->{mac}        = $crypto->{mac};
    $self->{iv}         = $crypto->{cipherparams}->{iv};
    $self->{version}    = $object->{version};
    $self->{id}         = $object->{id};

    my $header = $crypto->{kdfparams};

    $self->{kdf} = Blockchain::Ethereum::Keystore::KDF->new(
        algorithm => $crypto->{kdf},     #
        dklen     => $header->{dklen},
        n         => $header->{n},
        p         => $header->{p},
        r         => $header->{r},
        c         => $header->{c},
        prf       => $header->{prf},
        salt      => $header->{salt});

    $self->{private_key} = $self->_generate_private_key unless $self->private_key;
    $self->_verify_mac;

    return $self;
}

sub _verify_mac {
    my ($self) = @_;

    my $computed_mac = $self->_generate_mac;
    my $expected_mac = $self->mac;

    croak "Invalid password or corrupted keystore"
        unless lc $computed_mac eq lc $expected_mac;
}

sub _generate_mac {
    my ($self) = @_;

    my $derived_key = $self->kdf->decode($self->password);
    my $mac_key     = substr($derived_key, 16, 16);

    return unpack "H*", keccak256($mac_key . pack("H*", $self->ciphertext));
}

sub _generate_private_key {
    my ($self) = @_;

    my $derived_key = $self->kdf->decode($self->password);
    my $cipher_key  = substr($derived_key, 0, 16);

    my $key = $self->cipher->decrypt(pack("H*", $self->ciphertext), $cipher_key, pack("H*", $self->iv));

    return Blockchain::Ethereum::Key->new(private_key => $key);
}

sub _generate_random_iv {
    my $iv = Crypt::PRNG::random_bytes(16);
    return unpack "H*", $iv;
}

sub _generate_kdf {
    my ($self) = @_;

    my ($derived_key, $salt, $N, $r, $p) = Crypt::ScryptKDF::_scrypt_extra($self->password);
    return Blockchain::Ethereum::Keystore::KDF->new(
        algorithm => 'scrypt',
        dklen     => length $derived_key,
        n         => $N,
        p         => $p,
        r         => $r,
        salt      => unpack 'H*',
        $salt
    );
}

sub _generate_ciphertext {
    my ($self) = @_;

    my $derived_key = $self->kdf->decode($self->password);
    my $cipher_key  = substr($derived_key, 0, 16);

    my $encrypted = $self->cipher->encrypt($self->private_key->export, $cipher_key, pack("H*", $self->iv));
    return unpack "H*", $encrypted;
}

sub _generate_id {
    my $uuid = Data::UUID->new->create_str();
    $uuid =~ s/-//g;    # Remove hyphens for Ethereum format
    return lc($uuid);
}

sub write_to_file {
    my ($self, $file_path, $password) = @_;

    if ($password) {
        $self->{password} = $password;

        # regenerate required fields for password change
        delete $self->{$_} for qw(kdf iv ciphertext mac);
    }

    croak 'Password is required to encrypt the keystore'
        unless defined $self->password;

    my $file = {
        "crypto" => {
            "cipher"       => 'aes-128-ctr',
            "cipherparams" => {"iv" => $self->iv},
            "ciphertext"   => $self->ciphertext,
            "kdf"          => $self->kdf->algorithm,
            "kdfparams"    => {
                "dklen" => $self->kdf->dklen,
                "n"     => $self->kdf->n,
                "p"     => $self->kdf->p,
                "r"     => $self->kdf->r,
                "salt"  => $self->kdf->salt
            },
            "mac" => $self->mac
        },
        "id"      => $self->id,
        "version" => 3
    };

    open my $fh, '>:raw', $file_path
        or croak "Could not write to file '$file_path': $!";
    print $fh $json->encode($file);
    close $fh;

    return 1;
}

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

Blockchain::Ethereum::Keystore::File - Ethereum keystore file abstraction

=head1 VERSION

version 0.021

=head1 SYNOPSIS

    use Blockchain::Ethereum::Keystore::File;
    use Blockchain::Ethereum::Key;

    # Create a new keystore from a private key
    my $private_key = Blockchain::Ethereum::Key->new(
        private_key => $key_bytes
    );
    
    my $keystore = Blockchain::Ethereum::Keystore::File->new(
        private_key => $private_key,
        password    => 'my_secure_password'
    );

    # Save to file
    $keystore->write_to_file('/path/to/keystore.json');

    # Load from existing keystore file
    my $loaded = Blockchain::Ethereum::Keystore::File->from_file(
        '/path/to/keystore.json', 
        'my_secure_password'
    );

    # Change password and save
    $loaded->write_to_file('/path/to/new_keystore.json', 'new_password');

    # Access keystore properties
    my $private_key = $loaded->private_key;
    my $address = $private_key->address;

=head1 OVERVIEW

This module provides a way to create, read, and write Ethereum keystore files (version 3).
Ethereum keystores are encrypted JSON files that securely store private keys using 
password-based encryption with scrypt key derivation and AES-128-CTR cipher.

The module supports:

=over 4

=item * Creating new keystores from private keys

=item * Loading existing keystore files

=item * Password verification and changing

=item * Proper MAC validation for security

=back

=head1 METHODS

=head2 from_key

Load a keystore from an existing private key.

    my $key = Blockchain::Ethereum::Key->new(
        private_key => $key_bytes
    );
    my $keystore = Blockchain::Ethereum::Keystore::File->from_key($key);

=over 4

=item * C<key> - A Blockchain::Ethereum::Key instance (required)

=back

Returns a keystore object with the loaded private key and parameters.

=head2 from_file

Load a keystore from an existing file.

    my $keystore = Blockchain::Ethereum::Keystore::File->from_file(
        '/path/to/keystore.json',
        'password'
    );

=over 4

=item * C<file_path> - Path to the keystore JSON file (required)

=item * C<password> - Password to decrypt the keystore (required)

=back

Returns a keystore object with the loaded private key and parameters.

=head2 write_to_file

Write the keystore to a file, optionally with a new password.

    # Write with current password
    $keystore->write_to_file('/path/to/output.json');

    # Write with new password
    $keystore->write_to_file('/path/to/output.json', 'new_password');

=over 4

=item * C<file_path> - Path where to save the keystore file (required)

=item * C<password> - New password to encrypt with (optional)

=back

If a new password is provided, the keystore will be re-encrypted with the new password
while keeping the same private key.

Returns true on success, throws an exception on failure.

=head1 AUTHOR

REFECO <refeco@cpan.org>

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2022 by REFECO.



( run in 0.800 second using v1.01-cache-2.11-cpan-5837b0d9d2c )