Net-Nostr
view release on metacpan or search on metacpan
lib/Net/Nostr/KeyEncrypt.pm view on Meta::CPAN
package Net::Nostr::KeyEncrypt;
use strictures 2;
use Carp qw(croak);
use Crypt::AuthEnc::ChaCha20Poly1305;
use Crypt::PRNG qw(random_bytes);
use Crypt::ScryptKDF qw(scrypt_raw);
use Encode qw(encode);
use Unicode::Normalize qw(NFKC);
use Bitcoin::Crypto::Bech32 qw(encode_bech32 translate_5to8 translate_8to5);
use Exporter 'import';
our @EXPORT_OK = qw(
encrypt_private_key
decrypt_private_key
);
my $HEX64 = qr/\A[0-9a-f]{64}\z/;
my $VERSION_BYTE = 0x02;
my $MAX_BECH32_LENGTH = 5000;
# Bech32 decode without BIP-173's 90-char limit, same as in Bech32.pm
{
my @ALPHABET = qw(
q p z r y 9 x 8 g f 2 t v d w 0
s 3 j n 5 4 k h c e 6 m u a 7 l
);
my %ALPHABET_MAP = map { $ALPHABET[$_] => $_ } 0 .. $#ALPHABET;
my $CHARS = join '', @ALPHABET;
sub _polymod {
my ($values) = @_;
my @C = (0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3);
my $chk = 1;
for my $val (@$values) {
my $b = ($chk >> 25);
$chk = ($chk & 0x1ffffff) << 5 ^ $val;
for (0 .. 4) { $chk ^= (($b >> $_) & 1) ? $C[$_] : 0 }
}
return $chk;
}
sub _hrp_expand {
my @hrp = split //, shift;
return [map({ ord($_) >> 5 } @hrp), 0, map({ ord($_) & 31 } @hrp)];
}
sub _nostr_decode_bech32 {
my ($str) = @_;
$str = lc $str if uc $str eq $str;
croak "bech32 string exceeds 5000 character limit" if length($str) > $MAX_BECH32_LENGTH;
croak "bech32 string contains mixed case" if lc($str) ne $str;
my @parts = split /1/, $str;
croak "bech32 separator missing" if @parts < 2;
my $data_part = pop @parts;
my $hrp = join '1', @parts;
croak "invalid bech32 data characters" if $data_part !~ /\A[$CHARS]+\z/;
croak "bech32 data part too short" if length($data_part) < 6;
my @data_values = map { $ALPHABET_MAP{$_} } split //, $data_part;
my $check_values = [@{_hrp_expand($hrp)}, @data_values];
croak "invalid bech32 checksum" unless _polymod($check_values) == 1;
my @payload = @data_values[0 .. $#data_values - 6];
return ($hrp, \@payload);
}
}
sub encrypt_private_key {
my (%args) = @_;
my $privkey_hex = $args{privkey_hex} // croak "privkey_hex is required";
my $password = $args{password} // croak "password is required";
my $log_n = $args{log_n} // croak "log_n is required";
my $key_security = $args{key_security} // 0x02;
croak "privkey_hex must be 64-char lowercase hex" unless $privkey_hex =~ $HEX64;
croak "password is required" unless length $password;
croak "log_n must be between 1 and 22" unless $log_n >= 1 && $log_n <= 22;
croak "key_security must be 0x00, 0x01, or 0x02"
unless $key_security == 0x00 || $key_security == 0x01 || $key_security == 0x02;
my $privkey_raw = pack('H*', $privkey_hex);
$password = encode('UTF-8', NFKC($password));
my $salt = random_bytes(16);
my $nonce = random_bytes(24);
my $sym_key = scrypt_raw($password, $salt, 2**$log_n, 8, 1, 32);
my $aad = chr($key_security);
my $ciphertext_and_tag = _xchacha20poly1305_encrypt($sym_key, $nonce, $aad, $privkey_raw);
my $raw = chr($VERSION_BYTE) . chr($log_n) . $salt . $nonce . $aad . $ciphertext_and_tag;
my $data5 = translate_8to5($raw);
return encode_bech32('ncryptsec', $data5, 'bech32');
}
sub decrypt_private_key {
my ($ncryptsec, $password, %opts) = @_;
croak "ncryptsec string is required" unless defined $ncryptsec && length $ncryptsec;
croak "password is required" unless defined $password && length $password;
my ($hrp, $data5) = _nostr_decode_bech32($ncryptsec);
croak "expected ncryptsec prefix, got $hrp" unless $hrp eq 'ncryptsec';
my $raw = translate_5to8($data5);
croak "invalid payload size" unless length($raw) == 91;
my $version = ord(substr($raw, 0, 1));
croak "unknown version $version (expected $VERSION_BYTE)" unless $version == $VERSION_BYTE;
my $log_n = ord(substr($raw, 1, 1));
my $salt = substr($raw, 2, 16);
my $nonce = substr($raw, 18, 24);
my $aad = substr($raw, 42, 1);
my $ct_and_tag = substr($raw, 43);
$password = encode('UTF-8', NFKC($password));
# Use log_n from opts if provided, otherwise use embedded value
my $effective_log_n = $opts{log_n} // $log_n;
my $sym_key = scrypt_raw($password, $salt, 2**$effective_log_n, 8, 1, 32);
my $privkey_raw = eval { _xchacha20poly1305_decrypt($sym_key, $nonce, $aad, $ct_and_tag) };
croak "decryption failed: wrong password or corrupted data" unless defined $privkey_raw;
return unpack('H*', $privkey_raw);
}
# XChaCha20-Poly1305 built from HChaCha20 + IETF ChaCha20-Poly1305.
# HChaCha20 derives a 32-byte subkey from the first 16 bytes of the 24-byte
# nonce. The remaining 8 bytes (prepended with 4 zero bytes) become the
# 12-byte IETF nonce.
sub _xchacha20poly1305_encrypt {
my ($key, $nonce, $aad, $plaintext) = @_;
my ($subkey, $ietf_nonce) = _xchacha_derive($key, $nonce);
my $ae = Crypt::AuthEnc::ChaCha20Poly1305->new($subkey, $ietf_nonce);
$ae->adata_add($aad);
my $ct = $ae->encrypt_add($plaintext);
my $tag = $ae->encrypt_done();
return $ct . $tag;
}
sub _xchacha20poly1305_decrypt {
my ($key, $nonce, $aad, $ct_and_tag) = @_;
my ($subkey, $ietf_nonce) = _xchacha_derive($key, $nonce);
my $ct = substr($ct_and_tag, 0, length($ct_and_tag) - 16);
my $tag = substr($ct_and_tag, -16);
my $ae = Crypt::AuthEnc::ChaCha20Poly1305->new($subkey, $ietf_nonce);
$ae->adata_add($aad);
my $pt = $ae->decrypt_add($ct);
my $result_tag = $ae->decrypt_done();
croak "MAC mismatch" unless $result_tag eq $tag;
return $pt;
}
sub _xchacha_derive {
my ($key, $nonce) = @_;
my $subkey = _hchacha20($key, substr($nonce, 0, 16));
my $ietf_nonce = "\x00\x00\x00\x00" . substr($nonce, 16, 8);
return ($subkey, $ietf_nonce);
}
# HChaCha20: 20-round ChaCha core producing a 32-byte subkey from a
# 32-byte key and 16-byte nonce.
sub _hchacha20 {
my ($key, $nonce) = @_;
my @s = unpack('V*', "expand 32-byte k" . $key . $nonce);
for (1 .. 10) {
@s[0,4,8,12] = _quarter_round(@s[0,4,8,12]);
@s[1,5,9,13] = _quarter_round(@s[1,5,9,13]);
@s[2,6,10,14] = _quarter_round(@s[2,6,10,14]);
@s[3,7,11,15] = _quarter_round(@s[3,7,11,15]);
@s[0,5,10,15] = _quarter_round(@s[0,5,10,15]);
@s[1,6,11,12] = _quarter_round(@s[1,6,11,12]);
@s[2,7,8,13] = _quarter_round(@s[2,7,8,13]);
@s[3,4,9,14] = _quarter_round(@s[3,4,9,14]);
}
return pack('V4V4', @s[0..3], @s[12..15]);
}
sub _quarter_round {
my ($a, $b, $c, $d) = @_;
$a = ($a + $b) & 0xffffffff; $d = _rotl32($d ^ $a, 16);
$c = ($c + $d) & 0xffffffff; $b = _rotl32($b ^ $c, 12);
$a = ($a + $b) & 0xffffffff; $d = _rotl32($d ^ $a, 8);
$c = ($c + $d) & 0xffffffff; $b = _rotl32($b ^ $c, 7);
return ($a, $b, $c, $d);
}
sub _rotl32 {
my ($v, $n) = @_;
return (($v << $n) | (($v >> (32 - $n)) & ((1 << $n) - 1))) & 0xffffffff;
}
1;
__END__
=head1 NAME
Net::Nostr::KeyEncrypt - NIP-49 private key encryption
=head1 SYNOPSIS
use Net::Nostr::KeyEncrypt qw(encrypt_private_key decrypt_private_key);
# Encrypt a private key with a password
my $ncryptsec = encrypt_private_key(
privkey_hex => 'aa' x 32,
password => 'my-strong-password',
log_n => 16,
);
# ncryptsec1...
# Decrypt an encrypted private key
my $privkey_hex = decrypt_private_key($ncryptsec, 'my-strong-password');
# 'aa' x 32
# Specify key security level
my $ncryptsec = encrypt_private_key(
privkey_hex => $privkey_hex,
password => $password,
log_n => 20,
key_security => 0x01, # not known to have been handled insecurely
);
=head1 DESCRIPTION
Implements NIP-49 private key encryption. Encrypts a user's private key
with a password using scrypt key derivation and XChaCha20-Poly1305 AEAD
encryption. The output is a bech32-encoded C<ncryptsec> string that can
be stored or transferred safely.
The password is Unicode-normalized to NFKC form before use, ensuring
that the same password entered on different systems produces the same
encryption key.
=head1 FUNCTIONS
All functions are exportable. None are exported by default.
=head2 encrypt_private_key
my $ncryptsec = encrypt_private_key(
privkey_hex => $hex_privkey,
password => $password,
log_n => $log_n,
key_security => $byte, # optional, defaults to 0x02
);
Encrypts a private key with a password. Returns a bech32-encoded
C<ncryptsec> string. Croaks if any argument is missing, out of range,
or malformed.
Arguments:
=over 4
=item C<privkey_hex> - 64-char lowercase hex private key (required)
=item C<password> - encryption password (required, non-empty)
=item C<log_n> - scrypt cost parameter as a power of 2 (required, 1-22).
Higher values use more memory and time but are more resistant to brute
force. Recommended: 16 (64 MiB, ~100ms) for interactive use, 20+ for
long-term storage.
=item C<key_security> - one of C<0x00> (key known to have been handled
insecurely), C<0x01> (key not known insecure), or C<0x02> (unknown).
Defaults to C<0x02>. This byte is included as associated data in the
AEAD encryption and stored in the payload.
=back
my $ncryptsec = encrypt_private_key(
privkey_hex => 'aa' x 32,
password => 'my-strong-password',
log_n => 16,
);
=head2 decrypt_private_key
my $hex = decrypt_private_key($ncryptsec, $password);
my $hex = decrypt_private_key($ncryptsec, $password, log_n => $n);
Decrypts an C<ncryptsec> string with a password. Returns the private key
as a 64-char lowercase hex string. Validates the bech32 encoding,
C<ncryptsec> prefix, version byte (must be C<0x02>), and payload size
(must be 91 bytes). Croaks on wrong password, corrupted data, or
invalid format.
The C<log_n> parameter is optional. If omitted, the value embedded in
the C<ncryptsec> payload is used. Providing C<log_n> explicitly overrides
the embedded value, which is useful when you know the cost parameter
in advance.
my $hex = decrypt_private_key(
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
'nostr',
);
# '3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683'
=head1 SEE ALSO
L<NIP-49|https://github.com/nostr-protocol/nips/blob/master/49.md>,
L<Net::Nostr>, L<Net::Nostr::Key>
=cut
( run in 0.982 second using v1.01-cache-2.11-cpan-39bf76dae61 )