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 )