File Coverage

blib/lib/Crypt/Age/Header.pm
Criterion Covered Total %
statement 93 94 98.9
branch 12 22 54.5
condition 6 14 42.8
subroutine 13 13 100.0
pod 5 5 100.0
total 129 148 87.1


line stmt bran cond sub pod time code
1             package Crypt::Age::Header;
2             our $VERSION = '0.001';
3             our $AUTHORITY = 'cpan:GETTY';
4             # ABSTRACT: age file header parsing and generation
5              
6 4     4   92393 use Moo;
  4         6428  
  4         24  
7 4     4   2565 use Carp qw(croak);
  4         9  
  4         252  
8 4     4   427 use Crypt::Age::Primitives;
  4         6  
  4         106  
9 4     4   1746 use Crypt::Age::Stanza;
  4         15  
  4         190  
10 4     4   4076 use Crypt::Age::Stanza::X25519;
  4         15  
  4         157  
11 4     4   25 use namespace::clean;
  4         6  
  4         42  
12              
13              
14 4     4   1069 use constant VERSION_LINE => "age-encryption.org/v1";
  4         9  
  4         7014  
15              
16             has stanzas => (
17             is => 'ro',
18             default => sub { [] },
19             );
20              
21              
22             has mac => (
23             is => 'rw',
24             );
25              
26              
27             sub create {
28 13     13 1 276 my ($class, $file_key, $recipients) = @_;
29              
30 13         27 my @stanzas;
31 13         32 for my $recipient (@$recipients) {
32 15 50       145 if ($recipient =~ /^age1/) {
33 15         103 push @stanzas, Crypt::Age::Stanza::X25519->wrap($file_key, $recipient);
34             } else {
35 0         0 croak "Unsupported recipient format: $recipient";
36             }
37             }
38              
39 13         4019 my $header = $class->new(stanzas => \@stanzas);
40              
41             # Compute and set MAC
42 13         2852 my $header_bytes = $header->_header_bytes_for_mac;
43 13         65 my $mac = Crypt::Age::Primitives->compute_header_mac($file_key, $header_bytes);
44 13         70 $header->mac($mac);
45              
46 13         58 return $header;
47             }
48              
49              
50             sub to_string {
51 9     9 1 30 my ($self) = @_;
52              
53 9         40 my @lines = (VERSION_LINE);
54              
55 9         17 for my $stanza (@{$self->stanzas}) {
  9         31  
56 10         33 push @lines, $stanza->to_string;
57             }
58              
59             # MAC line
60 9         34 my $mac_b64 = Crypt::Age::Stanza::encode_base64_no_padding($self->mac);
61 9         27 push @lines, "--- $mac_b64";
62              
63 9         285 return join("\n", @lines) . "\n";
64             }
65              
66              
67             sub _header_bytes_for_mac {
68 25     25   57 my ($self) = @_;
69              
70 25         68 my @lines = (VERSION_LINE);
71              
72 25         46 for my $stanza (@{$self->stanzas}) {
  25         107  
73 31         123 push @lines, $stanza->to_string;
74             }
75              
76             # For MAC, we include everything up to but not including the MAC itself
77             # The footer line is "---" (without the MAC)
78 25         60 push @lines, "---";
79              
80 25         84 return join("\n", @lines);
81             }
82              
83             sub parse {
84 9     9 1 30 my ($class, $data_ref, $offset_ref) = @_;
85              
86 9         36 my $data = $$data_ref;
87 9   50     82 my $pos = $$offset_ref // 0;
88              
89             # Find header end (the line starting with ---)
90 9         33 my $header_end = index($data, "\n---", $pos);
91 9 50       22 croak "Invalid age file: no header footer found" if $header_end < 0;
92              
93             # Extract header text
94 9         29 my $header_text = substr($data, $pos, $header_end - $pos + 1);
95 9         38 my @lines = split /\n/, $header_text;
96              
97             # Check version
98 9         21 my $version_line = shift @lines;
99 9 50       21 croak "Invalid age version: $version_line" unless $version_line eq VERSION_LINE;
100              
101             # Parse stanzas
102 9         14 my @stanzas;
103 9         21 while (@lines) {
104 11         53 my $line = shift @lines;
105 11 50       32 last if $line =~ /^---/;
106              
107 11 50       59 if ($line =~ /^-> (\S+)\s*(.*)/) {
108 11         30 my $type = $1;
109 11         34 my @args = split /\s+/, $2;
110              
111             # Read body lines
112 11         17 my $body_b64 = '';
113 11   33     88 while (@lines && $lines[0] !~ /^->/ && $lines[0] !~ /^---/) {
      33        
114 11         21 my $body_line = shift @lines;
115 11         19 $body_b64 .= $body_line;
116 11 50       31 last if length($body_line) < 64; # Short line ends body
117             }
118              
119 11         33 my $body = Crypt::Age::Stanza::decode_base64_no_padding($body_b64);
120              
121 11         17 my $stanza_class = 'Crypt::Age::Stanza';
122 11 50       31 if ($type eq 'X25519') {
123 11         18 $stanza_class = 'Crypt::Age::Stanza::X25519';
124             }
125              
126 11         299 push @stanzas, $stanza_class->new(
127             type => $type,
128             args => \@args,
129             body => $body,
130             );
131             }
132             }
133              
134             # Parse MAC line
135 9         210 $pos = $header_end + 1; # Position after the newline before ---
136 9         21 my $footer_end = index($data, "\n", $pos);
137 9 50       20 $footer_end = length($data) if $footer_end < 0;
138              
139 9         21 my $footer_line = substr($data, $pos, $footer_end - $pos);
140 9 50       44 croak "Invalid footer: $footer_line" unless $footer_line =~ /^--- (\S+)$/;
141 9         25 my $mac = Crypt::Age::Stanza::decode_base64_no_padding($1);
142              
143             # Update offset to point after header
144 9         22 $$offset_ref = $footer_end + 1;
145              
146 9         191 return $class->new(
147             stanzas => \@stanzas,
148             mac => $mac,
149             );
150             }
151              
152              
153             sub verify_mac {
154 12     12 1 64 my ($self, $file_key) = @_;
155              
156 12         36 my $header_bytes = $self->_header_bytes_for_mac;
157 12         51 my $expected_mac = Crypt::Age::Primitives->compute_header_mac($file_key, $header_bytes);
158              
159 12         82 return $self->mac eq $expected_mac;
160             }
161              
162              
163             sub unwrap_file_key {
164 11     11 1 2401 my ($self, $identities) = @_;
165              
166 11         29 for my $identity (@$identities) {
167 11         18 for my $stanza (@{$self->stanzas}) {
  11         33  
168 13 50 33     161 if ($stanza->isa('Crypt::Age::Stanza::X25519') && $identity =~ /^AGE-SECRET-KEY-1/i) {
169 13         49 my $file_key = $stanza->unwrap($identity);
170 13 100 66     76 if (defined $file_key && $self->verify_mac($file_key)) {
171 10         76 return $file_key;
172             }
173             }
174             }
175             }
176              
177 1         205 croak "No matching identity found";
178             }
179              
180              
181              
182             1;
183              
184             __END__