Crypt-Age

 view release on metacpan or  search on metacpan

t/03-header.t  view on Meta::CPAN

    my $wrong_key = Crypt::Age::Primitives->generate_file_key;
    ok(!$header->verify_mac($wrong_key), 'MAC fails with wrong key');
}

# Test file key unwrapping
{
    my ($public, $secret) = Crypt::Age::Keys->generate_keypair;
    my $file_key = Crypt::Age::Primitives->generate_file_key;

    my $header = Crypt::Age::Header->create($file_key, [$public]);
    my $unwrapped = $header->unwrap_file_key([$secret]);

    is($unwrapped, $file_key, 'unwrapped file key matches');
}

# Test multiple recipients
{
    my ($public1, $secret1) = Crypt::Age::Keys->generate_keypair;
    my ($public2, $secret2) = Crypt::Age::Keys->generate_keypair;
    my $file_key = Crypt::Age::Primitives->generate_file_key;

    my $header = Crypt::Age::Header->create($file_key, [$public1, $public2]);

    is(scalar @{$header->stanzas}, 2, 'two stanzas for two recipients');

    my $unwrapped1 = $header->unwrap_file_key([$secret1]);
    is($unwrapped1, $file_key, 'first recipient can unwrap');

    my $unwrapped2 = $header->unwrap_file_key([$secret2]);
    is($unwrapped2, $file_key, 'second recipient can unwrap');
}

# A stanza body of exactly 64*n base64 characters requires an empty final
# line per the ABNF (final-line = *63base64char LF). PR #2 alone regressed
# this: it parsed the header text with split(/\n/, ...), which silently
# drops a trailing empty element, so the parser ran out of lines before
# seeing the required empty final line and died with "Invalid age stanza #1
# body". The filehandle-based line-by-line read restored correct handling.
# An X25519 body (32 bytes -> 43 base64 chars) never reaches this boundary,
# so this needs an unknown stanza type with a 48-byte body (64 base64
# chars) -- the parser doesn't validate stanza types, only structure.
{
    my $body = join '', map { chr($_ % 251) } 1 .. 48;
    my $body_b64 = Crypt::Age::Stanza::encode_base64_no_padding($body);
    is(length($body_b64), 64, 'fixture: body encodes to exactly 64 base64 chars');

    my $mac64 = Crypt::Age::Stanza::encode_base64_no_padding("\x00" x 32);
    my $str = join("\n",
        'age-encryption.org/v1',
        '-> stanza-test',
        $body_b64,
        '',            # required empty final line for a 64-char-multiple body
        "--- $mac64",
    ) . "\n";

    my $offset = 0;
    my $header = eval { Crypt::Age::Header->parse(\$str, \$offset) };
    is($@, '', 'header with an exact-64-char stanza body parses without dying');
    is(scalar @{$header->stanzas}, 1, 'one stanza parsed');
    is($header->stanzas->[0]->type, 'stanza-test', 'stanza type preserved');
    is(length($header->stanzas->[0]->body), 48, 'body decoded to the full 48 bytes');
    is($offset, length($str), 'offset lands at the end of the header');
}

# The header MAC must verify against the literal bytes that were read, not a
# re-serialization of the parsed stanzas (regression for commit 116444e):
# parse_from_fh passed the captured bytes under the constructor key 'bytes'
# while the attribute is '_bytes', so Moo silently dropped them and _bytes
# fell back to its lazy builder, which re-serializes the stanzas via
# Stanza::to_string instead of returning what was actually on the wire.
#
# This only shows up for a header our own writer cannot reproduce
# byte-for-byte: an extra unknown-type stanza whose body is exactly 64 base64
# characters, which requires an empty final line that Stanza::to_string
# omits (known gap, karr #3) -- the re-serialization comes out one byte
# short of the literal bytes. The MAC's correctness is not under test here
# (it's a fixed placeholder); only whether _bytes reflects the wire, so this
# needs no binary -- it's a literal-byte assertion per se.
{
    my ($public) = Crypt::Age::Keys->generate_keypair;
    my $file_key = Crypt::Age::Primitives->generate_file_key;
    my $stanza   = Crypt::Age::Stanza::X25519->wrap($file_key, $public);

    my $grease_body = join '', map { chr($_ % 251) } 1 .. 48;
    my $grease_body_b64 = Crypt::Age::Stanza::encode_base64_no_padding($grease_body);

    my $head_no_mac = join("\n",
        'age-encryption.org/v1',
        $stanza->to_string,
        '-> grease-test',
        $grease_body_b64,
        '',
        '---',
    );
    my $mac64 = Crypt::Age::Stanza::encode_base64_no_padding("\x00" x 32);
    my $str = "$head_no_mac $mac64\n";

    my $offset = 0;
    my $header = Crypt::Age::Header->parse(\$str, \$offset);

    is($header->_bytes, $head_no_mac,
        'captured header bytes match the literal input, not a re-serialization');
    is($offset, length($str), 'offset lands at the end of the crafted header');
}

# verify_mac must not compare the MAC byte-by-byte with an early return on the
# first mismatch (karr #7). Timing is not measurable in a test suite and
# nothing below tries, so be clear about what this can and cannot show: the
# accept/reject assertions hold for a plain string eq too and would not catch a
# revert. They pin the contract around the comparison -- a MAC that differs in
# exactly one byte is rejected whether that byte is the first or the last, and
# a MAC of the wrong length or none at all is rejected without dying.
#
# The one assertion with teeth is the warning check: eq on an undef MAC emits
# "Use of uninitialized value", slow_eq does not.
{
    my ($public) = Crypt::Age::Keys->generate_keypair;
    my $file_key = Crypt::Age::Primitives->generate_file_key;

    my $header = Crypt::Age::Header->create($file_key, [$public]);
    my $good   = $header->mac;



( run in 0.605 second using v1.01-cache-2.11-cpan-d01c6094234 )