Crypt-Age

 view release on metacpan or  search on metacpan

.claude/skills/crypt-age-core/SKILL.md  view on Meta::CPAN

| decrypt | `Header::unwrap_file_key`, `/^AGE-SECRET-KEY-1/i` | `Keys::decode_secret_key`, HRP compared with `lc` |

So an all-upper `AGE1...` recipient encrypts and an all-lower `age-secret-key-1...`
identity decrypts, both the way `rage` takes them; a mixed-case string of either kind
passes the prefix test and then dies in `bech32_decode` with `Invalid bech32: mixed
case`. Until ticket #19 the recipient dispatch alone was case-sensitive, so
`decode_public_key(uc $pub)` returned the right bytes while `encrypt` died with
`Unsupported recipient format` — do not re-narrow either regex to "match the binary",
and note that `age`'s refusal is strictness beyond the spec, not our standard.

None of this reaches the wire. The recipient string is decoded to raw bytes in
`Stanza::X25519::wrap` and the stanza carries the *ephemeral* public key, so a file
encrypted to `AGE1...` is byte-identical to the same file encrypted to `age1...`, and
`age` 1.2.1 reads both — even though it would not have accepted `AGE1...` as its own
`-r` argument. Generated keys stay canonical: `encode_public_key` is lowercase,
`encode_secret_key` uppercase.

## Proof — a green suite is not one

```bash
prove -lr t/                    # everything; note -r, plain -l t/ is not recursive

Changes  view on Meta::CPAN

    matches, so with none there is nothing that could match, pass at least
    one identity". Callers matching on either old text see the new one; both
    checks accept and reject exactly what they did before, and both still
    fire before any file key is generated or any header is parsed, so an
    empty list still costs nothing but the exception. The identities side
    closes no defect of its own -- Header->unwrap_file_key already died on an
    empty list, with "No matching identity found", and still does. The POD of
    create, encrypt, decrypt and encrypt_filehandle states the requirement
    and quotes the messages.
  - Crypt::Age::Keys->decode_secret_key and ->decode_public_key no longer
    quote the human-readable part they decoded when it is not the expected
    one. Both croaked "Invalid secret key HRP: expected 'age-secret-key-',
    got '...'" and its public-key equivalent, where the value after "got" is
    whatever bech32_decode returned as the HRP -- everything before the last
    "1" of the string the caller passed, so a prefix of the caller's own
    material, written into an exception raised inside this module and from
    there into any log that catches it. They now report "Invalid secret key
    HRP: expected the literal age-secret-key- prefix, pass an age identity
    rather than a recipient or some other Bech32 string" and "Invalid public
    key HRP: expected the literal age prefix, pass an age recipient rather
    than an identity or some other Bech32 string": the expected HRP is a

Changes  view on Meta::CPAN

    counterpart advising :raw rather than decoding -- instead of passing perl's
    EINVAL through from the in-memory open as "Cannot open input string: Invalid
    argument". Perl's own "code points over 0xFF" warning no longer fires
    either, because the check runs before the open. The POD of both methods now
    states the byte-string requirement.
  - encrypt and decrypt now reject a character string with a message that names
    the cause and the fix, instead of passing perl's EINVAL through from the
    in-memory open: "plaintext must be a byte string: it holds a code point
    above 0xFF, encode it before passing it in", and the ciphertext counterpart
    advising :raw rather than encoding, since wide ciphertext means binary that
    was decoded by mistake. Perl's own "code points over 0xFF" warning no longer
    fires either, because the check runs before the open. The POD now states the
    byte-string requirement for all six methods -- including its honest limit: a
    character string whose code points all fit in a byte is indistinguishable
    from bytes and is encrypted as Latin-1.
  - The LIMITATIONS section now documents both suffixes of the recipient
    rejection, ", got undef" and ", got an AGE-SECRET-KEY-1 identity", instead of
    only the base message.
  - Crypt::Age::Keys::bech32_decode no longer echoes the character it rejected.
    It now reports "Invalid bech32 character at offset N", a 0-based offset into
    the string that was passed in, so substr($encoded, $N, 1) lands on the
    character without quoting it. No byte of a real key was ever at risk --
    every character of an encoded age key is inside the Bech32 charset, so none
    can reach that branch -- but the method is public and takes any string, so a
    passphrase handed to it by mistake had one of its bytes put in an exception.
    Callers matching on the old message text must update.
  - encrypt and decrypt now croak instead of die when an in-memory open fails,
    so the error reports the caller's position rather than Crypt/Age.pm. This is
    reachable: perl refuses to open a string holding code points above 0xFF,
    which is what a caller gets for passing decoded characters where the API
    wants bytes.
  - An undef entry in the recipients array no longer emits two "Use of
    uninitialized value" warnings ahead of the error explaining it; it is
    rejected before any string operation touches it and reported as ", got
    undef" in the same message shape as the identity hint. An undef entry in
    identities is skipped without warning, which is what already happened to
    any identity that does not match -- no outcome changes there, only the noise.
  - SECURITY: two error paths no longer echo what the caller passed in. Header
    parsing died with "Invalid age version: $version_line", where $version_line
    is the first line of the supplied ciphertext -- and since the read is

Changes  view on Meta::CPAN

    nonce it requires and died; encrypt_payload_fh claimed to return the
    concatenated ciphertext when it returns nothing and writes to the handle;
    the ephemeral_public attribute claimed to be set on every stanza when the
    parse path leaves it undef; and the filehandle methods claimed to die on
    "file not found", which they cannot, since they open no files.
  - paranoid_read, bech32_encode and bech32_decode are documented.
  - Crypt::Age::Keys::bech32_decode now rejects a string that mixes upper- and
    lowercase, dying with "Invalid bech32: mixed case" before it even looks for
    the separator. BIP-173 requires this of decoders and c2sp.org/age repeats it
    for the key encoding; the checksum is computed over the lowercase form
    regardless, so a mixed-case key previously verified and decoded. age 1.2.1
    and rage 0.12.1 both reject such a string, so this closes a gap rather than
    tightening past them. All-uppercase and all-lowercase forms both still
    decode, and to the same bytes.
  - Crypt::Age::Stanza::to_bytes_for_mac and encode_body_base64 are removed.
    Neither was called anywhere in the distribution, and both were traps for a
    future caller: to_bytes_for_mac's name asserts a role in the header MAC that
    it has in neither direction -- Header::create re-serializes through
    to_string, Header::parse_from_fh MACs the literal bytes it read -- while
    encode_body_base64 returned the body unwrapped, which only coincides with
    the wire format because an X25519 body encodes to 43 characters and so never

Changes  view on Meta::CPAN

    type, whose argument is not the canonical unpadded base64 encoding of a
    32-byte value, or whose body is not exactly 32 bytes. These are header
    failures and die from Crypt::Age::Header->parse. Previously an extra
    argument was silently ignored, and a wrong-length body came back as "No
    matching identity found". Stanzas of other recipient types are unaffected
    and are still ignored rather than rejected.
  - Stanza serialization now emits the empty final line the format requires
    when a stanza body's base64 encoding is an exact multiple of 64
    characters. Files this distribution writes are byte-identical to before,
    since an X25519 body never reaches that boundary.
  - Unpadded base64 in headers is now decoded strictly: padding characters,
    characters outside the base64 alphabet, impossible lengths and
    non-canonical encodings are rejected rather than repaired, as the format
    requires. Malformed age files that previously decoded now die.
  - Crypt::Age::Header->verify_mac now compares the header MAC with
    Crypt::Misc::slow_eq instead of a plain string eq, so a wrong MAC is no
    longer rejected at the first differing byte. It returns 1 or 0 instead of
    1 or the empty string; a MAC of the wrong length, or none at all, is still
    false, no longer warns, and is never fatal.
  - X25519 key exchange now aborts when the shared secret is all zero, as
    c2sp.org/age requires. This is the low-order point check: it affects
    decryption of a file carrying a low-order ephemeral share and encryption
    to a low-order recipient key, both of which would otherwise proceed with a
    wrapping key an attacker can predict.

lib/Crypt/Age.pm  view on Meta::CPAN

    my ($class, %args) = @_;

    my $plaintext  = $args{plaintext}  // croak "plaintext required";
    my $recipients = $args{recipients} // croak "recipients required";

    # This is perl's own test for the in-memory open below, hoisted so it can
    # say what is wrong. PerlIO::scalar downgrades the string in place
    # (sv_utf8_downgrade) and, when that cannot be done, warns "Strings with
    # code points over 0xFF may not be mapped into in-memory file handles" and
    # returns EINVAL -- so the open croaked "open on input string: Invalid
    # argument", which tells a caller who passed decoded characters nothing at
    # all. Running the test first replaces that with a message naming the
    # cause, and the warning never happens because the open is never reached.
    #
    # Not utf8::is_utf8: it reports the internal representation, so a pure
    # ASCII string that happens to be stored upgraded answers true while
    # holding nothing above 0xFF. What decides this is the content.
    #
    # Not a /[^\x00-\xff]/ scan either, though both are free on an unflagged
    # string (perl short-circuits: downgrade on the flag, the regex on the
    # optimizer knowing that class cannot match a non-UTF-8 target -- measured

lib/Crypt/Age.pm  view on Meta::CPAN



sub decrypt {
    my ($class, %args) = @_;

    my $ciphertext = $args{ciphertext} // croak "ciphertext required";
    my $identities = $args{identities} // croak "identities required";

    # Same test, same reasons as in encrypt above; the advice differs because
    # age ciphertext is binary, so a wide character in it means the caller
    # decoded bytes that were never text rather than forgot to encode text.
    utf8::downgrade($ciphertext, 1)
        or croak 'ciphertext must be a byte string: it holds a code point '
            .'above 0xFF, read it with :raw rather than decoding it';

    open my $ifh, '<:raw', \$ciphertext or croak "open on input string: $!";

    my $output = '';
    open my $ofh, '>:raw', \$output or croak "open on output string: $!";

    $class->_decrypt_fh($ifh, $ofh, $identities);

lib/Crypt/Age.pm  view on Meta::CPAN

=item * C<input> - Input filehandle (required)

=item * C<output> - Output filehandle (required)

=item * C<recipients> - ArrayRef of Bech32-encoded public keys (required)

=back

Both filehandles will be forced to be C<:raw> using C<binmode>. That removes
every layer the caller had set, C<:encoding> included, so what is encrypted is
the bytes in C<input> and never characters decoded from them. This method
therefore needs no byte-string check of its own, unlike L</encrypt>: a handle
delivers octets by the time it is read from here.

The output stream will be in age format and can be decrypted with the C<age> or
C<rage> command-line tools.

Returns C<1> on success. Dies if a required argument is missing, if
C<recipients> is not a non-empty ArrayRef -- L</encrypt> quotes the two
messages, one for the shape and one for the empty list -- if a recipient string
is not a valid Bech32 C<age1...> public key, or if C<binmode> fails on either

lib/Crypt/Age/Header.pm  view on Meta::CPAN


An C<undef> entry dies with that same message and C<", got undef"> in place of
the identity hint; it is reported before any string operation touches it, so it
no longer produces two C<"Use of uninitialized value"> warnings ahead of the
error that explains it.

The offending string itself is never part of the message. It may be a secret
key, and the exception ends up in the caller's logs; the index locates the
entry without quoting it.

The case of the recipient string does not reach the file. It is decoded to raw
bytes here, and the stanza carries the ephemeral public key, not the recipient.

=head2 to_string

    my $header_text = $header->to_string;

Serializes the header to text format.

Returns a string containing the version line, all stanzas, and the MAC footer,
suitable for writing to the beginning of an age file.

lib/Crypt/Age/Keys.pm  view on Meta::CPAN

Encodes a 32-byte X25519 public key as a Bech32 string with HRP C<age>.

Returns a lowercase string starting with C<age1>.

=head2 decode_public_key

    my $public_bytes = Crypt::Age::Keys->decode_public_key('age1...');

Decodes a Bech32-encoded age public key to raw bytes.

Dies if the HRP is not C<age>, if the decoded data is not 32 bytes, or if the
string mixes upper- and lowercase; see L</bech32_decode>. The HRP is compared
case-insensitively, so an all-uppercase C<AGE1...> key is accepted as well.

The HRP mismatch is reported as C<"Invalid public key HRP: expected the
literal age prefix, pass an age recipient rather than an identity or some
other Bech32 string">. It names the expected HRP, which is a constant of the
format, and B<not> the one that arrived: the received HRP is everything before
the last C<1> of the string that was passed in, so it is a prefix of the
caller's own material. Here that material is a public key and no secret is at
stake, but the message reads the same as L</decode_secret_key>'s, where it is.

lib/Crypt/Age/Keys.pm  view on Meta::CPAN

Encodes a 32-byte X25519 secret key as a Bech32 string with HRP C<age-secret-key->.

Returns an uppercase string starting with C<AGE-SECRET-KEY-1>.

=head2 decode_secret_key

    my $secret_bytes = Crypt::Age::Keys->decode_secret_key('AGE-SECRET-KEY-1...');

Decodes a Bech32-encoded age secret key to raw bytes.

Dies if the HRP is not C<age-secret-key->, if the decoded data is not 32 bytes,
or if the string mixes upper- and lowercase; see L</bech32_decode>. The HRP is
compared case-insensitively, so an all-lowercase C<age-secret-key-1...> key is
accepted as well as the uppercase form L</encode_secret_key> emits.

The HRP mismatch is reported as C<"Invalid secret key HRP: expected the literal
age-secret-key- prefix, pass an age identity rather than a recipient or some
other Bech32 string">, and quotes no part of what arrived. The received HRP is
everything before the last C<1> of the caller's string, so a string whose HRP
is the opening characters of a real identity would have had those characters
written into an exception raised inside this module, where the caller can no

lib/Crypt/Age/Keys.pm  view on Meta::CPAN

This is the generic codec L</encode_public_key> and L</encode_secret_key> call;
most callers want those instead, since they also know the age HRPs and enforce
the 32-byte key length that this method does not.

=head2 bech32_decode

    my ($hrp, $bytes) = Crypt::Age::Keys->bech32_decode($encoded);

Decodes a Bech32 (BIP-173) string, verifying its checksum. Returns the
human-readable part exactly as it appeared in C<$encoded> (not lowercased) and
the decoded data as raw bytes.

This is the generic codec L</decode_public_key> and L</decode_secret_key> call;
most callers want those instead, since they also check the HRP and the decoded
length.

Dies if there is no C<1> separator, if the data part is empty, if it contains a
character outside the Bech32 charset, or if the checksum does not verify.

The charset failure names the position rather than the character: C<Invalid
bech32 character at offset N>, where C<N> is a 0-based offset into the string
that was passed in -- not into the data part after the separator -- so
C<substr($encoded, $N, 1)> is the character it rejected. Withholding the
character is deliberate. Anything a caller passes reaches here, and a string

lib/Crypt/Age/Primitives.pm  view on Meta::CPAN


    return;
}


sub decrypt_payload {
    my ($class, $payload_key, $ciphertext) = @_;

    # Same test, same reasons as in encrypt_payload above; the advice differs
    # because an age payload is binary, so a wide character in it means the
    # caller decoded bytes that were never text.
    utf8::downgrade($ciphertext, 1)
        or croak 'ciphertext must be a byte string: it holds a code point '
            .'above 0xFF, read it with :raw rather than decoding it';

    open my $ifh, '<:raw', \$ciphertext or croak "Cannot open input string: $!";

    my $output = '';
    open my $ofh, '>:raw', \$output or croak "Cannot open output string: $!";

    $class->decrypt_payload_fh($payload_key, $ifh, $ofh);

lib/Crypt/Age/Stanza.pm  view on Meta::CPAN

    # 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);

t/01-keys.t  view on Meta::CPAN


    # Keys should be deterministic length
    is(length($public), 62, 'public key has correct length');
    is(length($secret), 74, 'secret key has correct length');
}

# Test public key encoding/decoding roundtrip
{
    my $raw_key = "\x00" x 32;  # 32 zero bytes
    my $encoded = Crypt::Age::Keys->encode_public_key($raw_key);
    my $decoded = Crypt::Age::Keys->decode_public_key($encoded);

    is($decoded, $raw_key, 'public key roundtrip');
}

# Test secret key encoding/decoding roundtrip
{
    my $raw_key = "\xff" x 32;  # 32 0xff bytes
    my $encoded = Crypt::Age::Keys->encode_secret_key($raw_key);
    my $decoded = Crypt::Age::Keys->decode_secret_key($encoded);

    is($decoded, $raw_key, 'secret key roundtrip');
}

# Test public_key_from_secret
{
    my ($public, $secret) = Crypt::Age::Keys->generate_keypair;
    my $derived_public = Crypt::Age::Keys->public_key_from_secret($secret);

    is($derived_public, $public, 'public key derived from secret matches');
}

t/01-keys.t  view on Meta::CPAN

# which is exactly why the swap keeps its own assertions and the disclosure is
# measured in the #35 block below, on a string whose HRP carries real key
# characters.
{
    my ($public, $secret) = Crypt::Age::Keys->generate_keypair;

    eval { Crypt::Age::Keys->decode_public_key($secret) };
    like($@, qr/^Invalid public key HRP: expected the literal age prefix, /,
        'decode_public_key on a secret key string reports the documented HRP mismatch');
    unlike($@, qr/AGE-SECRET-KEY-/i,
        'and does not name the HRP it decoded, not even as the other type prefix');

    eval { Crypt::Age::Keys->decode_secret_key($public) };
    like($@, qr/^Invalid secret key HRP: expected the literal age-secret-key- prefix, /,
        'decode_secret_key on a public key string reports the documented HRP mismatch');
    # The mirror assertion cannot be written the same way round: the HRP that
    # arrived here is "age", which is a substring of the expected constant this
    # message names on purpose. What is asserted instead is that the clause
    # reporting what arrived is gone altogether.
    unlike($@, qr/got /,
        'and carries the requirement and the fix rather than what arrived');

t/01-keys.t  view on Meta::CPAN

        my $data_offset = $offset - rindex($input, '1') - 1;
        unlike($err, qr/at offset \Q$data_offset\E:/,
            'the number is an offset into the whole string, not into the data part');

        ok(index($err, $marker) < 0,
            'the offending character itself does not appear in the message');
    }
}

# Ticket #35: both HRP-mismatch croaks used to interpolate the HRP they
# decoded. bech32_decode returns everything before the last "1" of the string
# it was handed, so that value is a prefix of the caller's own material,
# written into an exception raised inside this module.
#
# Reachability, measured before this block was written rather than assumed, on
# a freshly generated secret key: truncated inside the HRP it dies at "Invalid
# bech32: no separator", truncated at the separator at "Invalid bech32: empty
# data", truncated anywhere in the data part or given trailing junk at "Invalid
# bech32 checksum". None of those quote anything. A string therefore only
# reaches the HRP croak when its Bech32 checksum verifies over the wrong HRP --
# a constructed input rather than a mistyped one, which is why this ticket is
# low and not the disclosure #34 was. Constructed is not unreachable: a string
# whose HRP is the opening characters of a real identity is precisely how those
# characters would be read back out of an exception.
#
# That is the input built below. 32 bytes are encoded under an HRP that is the
# first 31 characters of a freshly generated key, so the checksum verifies and
# the decoded HRP comes back as those 31 characters. The secret half is encoded
# through the lowercased HRP because bech32_verify_checksum checks against
# lc($hrp), and uppercased afterwards so the string is not mixed case -- which
# bech32_decode refuses before it ever looks at the HRP -- and carries the
# key's own casing.
#
# 31 characters and not the whole key, deliberately: a leak assertion has to be
# able to fail in the red state, and what the red state put in the message was
# the decoded HRP, so it is the fixture that has to bound the length. The
# 32-character limit that shaped the same assertions in #34 is perl's own, on
# the strings perl quotes into its dereference errors; these croaks are this
# module's and truncate nothing.
#
# Every assertion below is index() inside ok(), or is() on a count. Never
# like()/unlike()/is_deeply on $err: those print the value they were given when
# they fail, so a red run would write the same key characters into the test
# output that the bug writes into a caller's log.
{
    my ($public, $secret) = Crypt::Age::Keys->generate_keypair;

t/01-keys.t  view on Meta::CPAN

    {
        local $SIG{__WARN__} = sub { push @warn, $_[0] };
        local $@;
        eval { Crypt::Age::Keys->decode_secret_key($crafted) };
        $err = $@;
    }

    ok(index($err, 'Invalid secret key HRP: expected the literal age-secret-key- prefix') == 0,
        'a checksum that verifies over the wrong HRP is refused, naming the expected HRP');
    ok(index($err, $secret_hrp) == -1,
        'and no part of the HRP it decoded reaches the message');
    ok(index($err, $secret_chars) == -1,
        'not even the 15 characters of key material that HRP carried');
    is(scalar @warn, 0, 'and nothing warns out of Keys.pm');
    ok(index($err, 'Crypt/Age/Keys.pm') == -1,
        'it croaks: Keys.pm is not blamed as the origin');

    # The same shape on the public half. A public key is not a secret, so this
    # is not a disclosure -- it is the same defect, and the ticket asks for both
    # methods to read the same way, so it is asserted the same way.
    my $public_hrp = substr($public, 0, 31);

t/01-keys.t  view on Meta::CPAN

    {
        local $SIG{__WARN__} = sub { push @warn_public, $_[0] };
        local $@;
        eval { Crypt::Age::Keys->decode_public_key($crafted_public) };
        $err_public = $@;
    }

    ok(index($err_public, 'Invalid public key HRP: expected the literal age prefix') == 0,
        'the public half is refused the same way, naming the expected HRP');
    ok(index($err_public, $public_hrp) == -1,
        'and no part of the HRP it decoded reaches the message');
    ok(index($err_public, substr($public, 4, 27)) == -1,
        'not even the 27 characters of key material that HRP carried');
    is(scalar @warn_public, 0, 'and nothing warns out of Keys.pm');

    # Counter-proof that the two cases above measure the HRP check and not a
    # string this module could not have decoded in the first place.
    is(length(Crypt::Age::Keys->decode_secret_key($secret)), 32,
        'the untouched secret key still decodes to 32 bytes');
    is(length(Crypt::Age::Keys->decode_public_key($public)), 32,
        'the untouched public key still decodes to 32 bytes');
}

done_testing;

t/02-encrypt-decrypt.t  view on Meta::CPAN

    ok($err, 'decrypting a no-newline non-age input dies');
    like($err,
        qr/^Invalid age version: expected the literal age-encryption\.org\/v1 version line/,
        'the message names the expected literal version line');
    ok(index($err, $not_an_age_file) == -1,
        'none of the input content appears in the error');
}

# Ticket #26, carrying the #24 regression: the string API takes bytes. Perl
# refuses to map a string holding a code point above 0xFF into an in-memory
# handle, so handing encrypt/decrypt a character (decoded) string used to fail
# at the *input* open. #24 made that open croak instead of die, which put the
# blame on the caller -- those location assertions are kept below, they are
# the point of the block. #26 replaces what the caller is told: EINVAL from an
# in-memory open ("open on input string: Invalid argument") names no cause and
# suggests no fix, so encrypt/decrypt now run perl's own downgrade test first
# and say what is wrong. The open is no longer reached on this path, so perl's
# "code points over 0xFF" warning is gone too -- asserted, not suppressed.
#
# The output-side opens (on a lexical the method owns, written through :raw)
# have no caller-reachable failure and are deliberately not covered here.

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

        $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.
#



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