File Coverage

blib/lib/Crypt/Age/Keys.pm
Criterion Covered Total %
statement 109 109 100.0
branch 20 30 66.6
condition n/a
subroutine 17 17 100.0
pod 6 12 50.0
total 152 168 90.4


line stmt bran cond sub pod time code
1             package Crypt::Age::Keys;
2             our $VERSION = '0.001';
3             our $AUTHORITY = 'cpan:GETTY';
4             # ABSTRACT: Key generation and Bech32 encoding for age encryption
5              
6 5     5   157510 use Moo;
  5         9778  
  5         28  
7 5     5   3201 use Carp qw(croak);
  5         12  
  5         246  
8 5     5   2139 use Crypt::PK::X25519;
  5         74513  
  5         249  
9 5     5   2260 use namespace::clean;
  5         74911  
  5         61  
10              
11              
12             # Bech32 character set
13             my $BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
14             my %BECH32_CHAR_TO_VAL = map { substr($BECH32_CHARSET, $_, 1) => $_ } 0..31;
15              
16             # Human-readable parts
17             my $HRP_PUBLIC = 'age';
18             my $HRP_SECRET = 'age-secret-key-';
19              
20             sub generate_keypair {
21 18     18 1 802726 my ($class) = @_;
22              
23 18         117 my $pk = Crypt::PK::X25519->new;
24 18         42370 $pk->generate_key;
25              
26 18         272 my $secret_bytes = $pk->export_key_raw('private');
27 18         96 my $public_bytes = $pk->export_key_raw('public');
28              
29 18         132 my $public_key = $class->encode_public_key($public_bytes);
30 18         93 my $secret_key = $class->encode_secret_key($secret_bytes);
31              
32 18         345 return ($public_key, $secret_key);
33             }
34              
35              
36             sub encode_public_key {
37 21     21 1 5774 my ($class, $bytes) = @_;
38 21 100       332 croak "Public key must be 32 bytes" unless length($bytes) == 32;
39 20         91 return $class->bech32_encode($HRP_PUBLIC, $bytes);
40             }
41              
42              
43             sub decode_public_key {
44 17     17 1 885 my ($class, $encoded) = @_;
45 17         63 my ($hrp, $bytes) = $class->bech32_decode($encoded);
46 16 50       127 croak "Invalid public key HRP: expected '$HRP_PUBLIC', got '$hrp'"
47             unless lc($hrp) eq $HRP_PUBLIC;
48 16 50       54 croak "Invalid public key length" unless length($bytes) == 32;
49 16         68 return $bytes;
50             }
51              
52              
53             sub encode_secret_key {
54 19     19 1 1042 my ($class, $bytes) = @_;
55 19 50       102 croak "Secret key must be 32 bytes" unless length($bytes) == 32;
56 19         63 return uc($class->bech32_encode($HRP_SECRET, $bytes));
57             }
58              
59              
60             sub decode_secret_key {
61 15     15 1 43 my ($class, $encoded) = @_;
62 15         61 my ($hrp, $bytes) = $class->bech32_decode($encoded);
63 15 50       66 croak "Invalid secret key HRP: expected '$HRP_SECRET', got '$hrp'"
64             unless lc($hrp) eq $HRP_SECRET;
65 15 50       44 croak "Invalid secret key length" unless length($bytes) == 32;
66 15         54 return $bytes;
67             }
68              
69              
70             sub public_key_from_secret {
71 1     1 1 12 my ($class, $secret_key) = @_;
72 1         7 my $secret_bytes = $class->decode_secret_key($secret_key);
73 1         12 my $pk = Crypt::PK::X25519->new;
74 1         101 $pk->import_key_raw($secret_bytes, 'private');
75 1         2388 my $public_bytes = $pk->export_key_raw('public');
76 1         7 return $class->encode_public_key($public_bytes);
77             }
78              
79              
80             # Bech32 implementation (BIP-173)
81              
82             sub bech32_polymod {
83 72     72 0 176 my ($values) = @_;
84 72         148 my @GEN = (0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3);
85 72         122 my $chk = 1;
86 72         145 for my $v (@$values) {
87 5384         7093 my $b = $chk >> 25;
88 5384         7736 $chk = (($chk & 0x1ffffff) << 5) ^ $v;
89 5384         7649 for my $i (0..4) {
90 26920 100       50374 $chk ^= (($b >> $i) & 1) ? $GEN[$i] : 0;
91             }
92             }
93 72         413 return $chk;
94             }
95              
96             sub bech32_hrp_expand {
97 72     72 0 199 my ($hrp) = @_;
98 72         102 my @result;
99 72         360 for my $c (split //, $hrp) {
100 620         1104 push @result, ord($c) >> 5;
101             }
102 72         187 push @result, 0;
103 72         250 for my $c (split //, $hrp) {
104 620         950 push @result, ord($c) & 31;
105             }
106 72         1133 return \@result;
107             }
108              
109             sub bech32_create_checksum {
110 40     40 0 122 my ($hrp, $data) = @_;
111 40         72 my @values = (@{bech32_hrp_expand($hrp)}, @$data, 0, 0, 0, 0, 0, 0);
  40         126  
112 40         128 my $polymod = bech32_polymod(\@values) ^ 1;
113 40         81 my @checksum;
114 40         76 for my $i (0..5) {
115 240         638 push @checksum, ($polymod >> (5 * (5 - $i))) & 31;
116             }
117 40         244 return \@checksum;
118             }
119              
120             sub bech32_verify_checksum {
121 32     32 0 113 my ($hrp, $data) = @_;
122 32         53 return bech32_polymod([@{bech32_hrp_expand($hrp)}, @$data]) == 1;
  32         94  
123             }
124              
125             sub bech32_encode {
126 40     40 0 925 my ($class, $hrp, $bytes) = @_;
127              
128             # Convert 8-bit bytes to 5-bit groups
129 40         324 my $data = $class->_convert_bits([unpack('C*', $bytes)], 8, 5, 1);
130              
131 40         242 my $checksum = bech32_create_checksum($hrp, $data);
132 40         190 my @combined = (@$data, @$checksum);
133              
134 40         104 my $result = $hrp . '1';
135 40         80 for my $d (@combined) {
136 2268         4091 $result .= substr($BECH32_CHARSET, $d, 1);
137             }
138              
139 40         324 return $result;
140             }
141              
142             sub bech32_decode {
143 33     33 0 960 my ($class, $str) = @_;
144              
145             # Find separator
146 33         96 my $sep_pos = rindex($str, '1');
147 33 100       275 croak "Invalid bech32: no separator" if $sep_pos < 1;
148 32 50       97 croak "Invalid bech32: empty data" if $sep_pos + 1 >= length($str);
149              
150 32         94 my $hrp = substr($str, 0, $sep_pos);
151 32         106 my $data_part = lc(substr($str, $sep_pos + 1));
152              
153             # Decode data part
154 32         58 my @data;
155 32         409 for my $c (split //, $data_part) {
156 1804 50       3139 croak "Invalid bech32 character: $c" unless exists $BECH32_CHAR_TO_VAL{$c};
157 1804         2670 push @data, $BECH32_CHAR_TO_VAL{$c};
158             }
159              
160 32 50       267 croak "Invalid bech32 checksum"
161             unless bech32_verify_checksum(lc($hrp), \@data);
162              
163             # Remove checksum (last 6 values)
164 32         160 splice(@data, -6);
165              
166             # Convert 5-bit groups back to 8-bit bytes
167 32         153 my $bytes = $class->_convert_bits(\@data, 5, 8, 0);
168              
169 32         339 return ($hrp, pack('C*', @$bytes));
170             }
171              
172             sub _convert_bits {
173 72     72   244 my ($class, $data, $from_bits, $to_bits, $pad) = @_;
174              
175 72         151 my $acc = 0;
176 72         147 my $bits = 0;
177 72         113 my @result;
178 72         154 my $maxv = (1 << $to_bits) - 1;
179              
180 72         166 for my $v (@$data) {
181 2860         4044 $acc = ($acc << $from_bits) | $v;
182 2860         3528 $bits += $from_bits;
183 2860         7230 while ($bits >= $to_bits) {
184 2981         3591 $bits -= $to_bits;
185 2981         7374 push @result, ($acc >> $bits) & $maxv;
186             }
187             }
188              
189 72 100       174 if ($pad) {
190 40 100       98 if ($bits > 0) {
191 39         83 push @result, ($acc << ($to_bits - $bits)) & $maxv;
192             }
193             } else {
194 32 50       104 croak "Invalid padding" if $bits >= $from_bits;
195 32 50       112 croak "Non-zero padding" if (($acc << ($to_bits - $bits)) & $maxv);
196             }
197              
198 72         247 return \@result;
199             }
200              
201              
202             1;
203              
204             __END__