Crypt-Age
view release on metacpan or search on metacpan
lib/Crypt/Age/Stanza.pm view on Meta::CPAN
package Crypt::Age::Stanza;
# ABSTRACT: Base class for age recipient stanzas
our $VERSION = '0.003';
use Moo;
use Carp qw(croak);
use MIME::Base64 qw(encode_base64 decode_base64);
use namespace::clean;
has type => (
is => 'ro',
required => 1,
);
has args => (
is => 'ro',
default => sub { [] },
);
has body => (
is => 'ro',
default => '',
);
sub encode_base64_no_padding {
my ($data) = @_;
my $encoded = encode_base64($data, '');
$encoded =~ s/=+$//; # Remove padding
return $encoded;
}
sub decode_base64_no_padding {
my ($encoded) = @_;
# "decoders MUST reject non-canonical encodings and encodings ending with
# '=' padding characters" -- c2sp.org/age. MIME::Base64 does neither: it
# silently skips characters outside the alphabet, drops a stray trailing
# character, and ignores the unused bits of the last group. Every check
# below is therefore ours, and none of them names $encoded in its message --
# a stanza body is wrapped key material.
croak "Invalid base64: '=' padding is not allowed in the age format"
if $encoded =~ m{=};
croak "Invalid base64: character outside the RFC 4648 section 4 alphabet"
if $encoded =~ m{[^A-Za-z0-9+/]};
croak "Invalid base64: length is not a valid unpadded encoding"
if length($encoded) % 4 == 1;
my $pad = (4 - length($encoded) % 4) % 4;
my $decoded = decode_base64($encoded . ('=' x $pad));
# Re-encode and compare. This is what catches non-canonical trailing bits:
# the unused low bits of the final group must be zero, so the canonical
# encoding of the decoded bytes has to be byte-identical to the input.
croak "Invalid base64: non-canonical encoding"
unless encode_base64_no_padding($decoded) eq $encoded;
return $decoded;
}
sub to_string {
my ($self) = @_;
my @parts = ('->', $self->type, @{$self->args});
my $header_line = join(' ', @parts);
my $body_b64 = encode_base64_no_padding($self->body);
# Split into 64-char lines. The ABNF is
# stanza = arg-line *full-line final-line
# final-line = *63base64char LF
# so the final line is at most 63 characters and a body whose encoding is an
# exact multiple of 64 MUST be followed by an empty final line -- hence >=
# and not >. Header::parse's matching `last if $len < 64` depends on it, and
# so does the header MAC.
my @lines = ($header_line);
while (length($body_b64) >= 64) {
push @lines, substr($body_b64, 0, 64, '');
}
push @lines, $body_b64; # final line, empty when the body ended flush at 64
return join("\n", @lines);
}
1;
__END__
=pod
=encoding UTF-8
=head1 NAME
Crypt::Age::Stanza - Base class for age recipient stanzas
=head1 VERSION
version 0.003
=head1 SYNOPSIS
use Crypt::Age::Stanza;
# Create a stanza
my $stanza = Crypt::Age::Stanza->new(
type => 'X25519',
args => ['base64-encoded-ephemeral-key'],
body => $wrapped_file_key_bytes,
);
# Serialize to string
my $text = $stanza->to_string;
# -> X25519 base64-encoded-ephemeral-key
( run in 2.027 seconds using v1.01-cache-2.11-cpan-d01c6094234 )