Skip to content

format: archives pad where Go can read them, and can be locked with the old scheme too - #31

Merged
donislawdev merged 2 commits into
mainfrom
format/archives-pad-and-lock
Sep 1, 2026
Merged

format: archives pad where Go can read them, and can be locked with the old scheme too#31
donislawdev merged 2 commits into
mainfrom
format/archives-pad-and-lock

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

Two commits. One fixes a fault nobody knew about, the other adds the lock that
finds faults in other people's code.

A .tar.gz pads where Go can read it

Breaking. Sizes do not move, every size that was reachable still is, and
every reader that took these files still takes them. What moves is where the
padding sits in the gzip header, and with it the bytes.

Go's compress/gzip reads a header comment into a fixed buffer: 511 bytes it
takes, 512 it refuses.
This format padded through that comment up to four
kilobytes, so:

before after
reachable sizes 11 260 11 260
unreachable 0 0
unreadable by Go 4134 (37%) 0

The message Go gives is gzip: invalid header, which reads like a corrupt file
rather than like a field the reader will not take - so anybody meeting it would
suspect the fixture rather than the tool that made it. Testers write tools in Go.

Why it went a month unnoticed is worth more than the fix. Every reference
tool took those files: 7-Zip, GNU tar, bsdtar, Python and node accept a comment
of ten megabytes without a word, so the oracle was green on all of them. The
channel HAD been measured, carefully, in August - and the measurement asked five
readers, none of which was the language this is written in.

Both fixes named in the observation were measured and rejected. Capping the
comment at 511 gives zero unreadable archives and takes 831 sizes out of
reach
, because a tar filler entry cannot be smaller than one 512 byte block
and nothing bridges the gap under it. Trading 4134 unreadable files for 831
refusals is not a fix, and it would have read like one in a summary.

So the padding moved to the gzip extra field, measured the same day rather
than assumed: Go takes 65 531 bytes of it, to the last one, beside a comment, at
a cost of exactly two bytes more than it carries. Byte granular, where the
filler entry is aligned to 512. The comment keeps the label and stretches by at
most one byte - the one amount the extra field cannot do - so the pair reach
every size between them.

Three golden hashes moved, recorded through the remeasured list with the
measurement. There is no way back and the changelog says so: the old bytes are
the ones Go cannot read.

A zip can be locked with ZipCrypto

--set encryption=zipcrypto. It is here for what it does to a reader rather
than for what it protects, measured on our own output:

archive what .NET's ZipFile does
ZipCrypto opens it, reports Length=8192, hands back ff c7 04 3e where the file holds tfg - txt - ciphertext as data, and never a word that the entry was encrypted
AES-256 opens it and throws InvalidDataException

An application built on that library processes noise and calls it data. AES
fails loudly in the same library, which is the safer defect and the less
interesting one. This is the fixture PRESETS.md section 4.12 has been
describing since before there was anything to build it with.

It is not protection and is not offered as any. ZipCrypto has been broken
for decades. aes-256 is the one to use when the point is that the contents are
hard to read.

The cipher was checked against somebody else's file before it was used

It went into tools/probes/zipcrypto before the generator, pointed at an
archive 7-Zip had written, and asked to decrypt it. Check byte, plaintext CRC
and the bytes all came back right.

That order was chosen because of what the AES work had shown hours earlier: a
mutation proved the archiver guard could not see a keystream running backwards,
since an AE-2 entry signs its ciphertext rather than its contents, so a file of
noise passes every check a reader makes.

A defect got through anyway, and the probe is what diagnosed it. Every
locked entry declared method 99 - WinZip AES - while a ZipCrypto entry has to
stay stored. 7-Zip reported Data Error in encrypted file. Wrong password?,
pointing at the password, the one thing that was right. The probe decrypted the
same file perfectly, and that moved the suspicion off the cryptography and onto
the header.

TestALockedEntryDeclaresTheShapeItsSchemeRequires now asks both schemes in
both directions: ZipCrypto stores, carries the real plaintext CRC and no extra
field. AES declares 99, carries a CRC of nought and a 0x9901 field.

The cost, named

ZipCrypto puts the high byte of the plaintext CRC in its header, so the contents
have to be known before the first byte of the entry goes out - and they arrive
as a stream. Each entry is generated twice, once to be checksummed and once
to be encrypted. Twice the processor and not a byte more memory, because
buffering would break the guard that says a generator does not hold a whole
file.

Evidence

  • tools/probes/targzpadding priced the padding change and is the reason the
    obvious fix was not taken. Run it before and after any change to a padding
    limit: it asks which sizes are REACHABLE and which are READABLE, and those are
    two questions.
  • TestEveryArchiveThisToolWritesCanBeReadByTheStandardLibrary reads 1762
    archives across both containers with the standard library.
  • 7-Zip opens every locked archive with the right password and refuses the wrong
    one and none at all, for all four schemes, and the extracted contents are
    compared against the same archive built unlocked.
  • Three guards stopped carrying a hand written list of encryption methods and
    read the registry instead.
  • Nine mutations across the two commits, all caught. preflight --quick: 11 of
    11, including staticcheck, lint and fidelity.

donislawdev and others added 2 commits September 1, 2026 12:11
…ded before

O163, and a breaking change the owner asked for. Sizes do not move and every
size that was reachable still is. What moves is where the padding sits inside
the gzip header, and with it the bytes of every .tar.gz this tool writes.

The fault was invisible for a month and each reason it stayed that way is worth
naming, because none of them was carelessness.

Go's compress/gzip reads a header comment into a fixed buffer: 511 bytes it
takes, 512 it refuses. This format padded through that comment up to four
kilobytes, so 4134 of the 11 260 reachable sizes produced an archive no Go
program could open. The message it gives is "gzip: invalid header", which reads
like a corrupt file rather than like a field the reader will not take, so
anybody meeting it would suspect the fixture rather than the tool that made it.
Testers write tools in Go.

Every reference tool took those files. 7-Zip, GNU tar, bsdtar, Python and node
accept a comment of ten megabytes without a word, so the oracle was green on all
of them. The channel had been measured carefully in August and written up, and
the measurement asked five readers - the language this is written in was not
among them. That is the same shape as trusting a document instead of measuring:
the question was right and the sample was one entry short.

Both fixes named in the observation were measured and rejected. Capping the
comment at what Go takes gives zero unreadable archives and takes 831 sizes out
of reach, because a tar filler entry cannot be smaller than one 512 byte block
and nothing bridges the gap under it. Trading 4134 unreadable files for 831
refusals is not a fix, and it would have looked like one in a summary.

So the padding moved to the gzip extra field, which was measured the same day
rather than assumed: Go takes 65 531 bytes of it, to the last one, beside a
comment, at a cost of exactly two bytes more than it carries. Byte granular,
where the filler entry is aligned to 512. The comment keeps the label and
stretches by at most one byte, which is the one amount the extra field cannot
do, so the pair together reach every size.

After: 11 260 reachable, 0 unreachable, 0 unreadable. Reachability identical to
before, which is the number that decided it. 7-Zip, GNU tar and Python were run
on real output afterwards and all three open it.

Three golden hashes moved, recorded through the remeasured list with the
measurement, because editing them quietly is the single move that turns that
guard into decoration. There is no way back to the old bytes and the changelog
says so: the old bytes are the ones Go cannot read, so a switch for them would
be a switch for the fault.

TestEveryArchiveThisToolWritesCanBeReadByTheStandardLibrary now asks both
containers, across the band where the padding channel changes shape, whether the
standard library opens what we wrote. 1762 archives. It is the guard that would
have caught this on the day it went in, and the reason it did not exist is that
every check we had asked somebody else's reader.

The sweep that priced all of this is tools/probes/targzpadding, and it asks two
questions rather than one: which sizes are REACHABLE and which of those are
READABLE. Run it before and after any change to a padding limit. Reachability is
the one that gets worse quietly.

validCost, commentOfCost and commentCapacity had no readers left afterwards and
are gone. The measurement commentCapacity carried moved into the comment on the
limit that still binds, because a fact does not stop being true when the
constant holding it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aving for what it breaks

    tfg generate --format zip --size 30kb --set entries=3 \
      --set password=Secret123 --set encryption=zipcrypto

It is here for what it does to a reader rather than for what it protects.
Measured on our own output: .NET's ZipFile opens one of these, reports the entry
at its true length of 8192, hands back a stream and fills it with ff c7 04 3e
where the file holds "tfg - txt". It never says the entry was encrypted at all,
so an application built on that library processes noise and calls it data. AES
in the same library throws, which is the safer defect and the less interesting
one. This is the fixture PRESETS.md section 4.12 has been describing since
before there was anything to build it with.

The cipher is not ours and it was not taken on trust. It went into
tools/probes/zipcrypto BEFORE it went into the generator, pointed at an archive
7-Zip had written, and asked to decrypt it - check byte, plaintext CRC and the
bytes themselves all came back right. That order was chosen because of what the
AES work had shown a few hours earlier: a mutation proved the archiver guard
could not see a keystream running backwards, since an AE-2 entry signs its
ciphertext rather than its contents, so a file of noise passes every check a
reader makes.

A defect got through anyway, and the probe is what diagnosed it. Every locked
entry declared method 99 - WinZip AES - while a ZipCrypto entry has to stay
stored, because it changes nothing about how the bytes sit and only puts twelve
in front of them. 7-Zip reported "Data Error in encrypted file. Wrong password?"
which points at the password, the one thing that was right. The probe decrypted
the same file perfectly, and that is what moved the suspicion off the
cryptography and onto the header.

So there is a guard for the header shape now, and it asks both schemes in both
directions. ZipCrypto stores, carries the real plaintext CRC and no extra field.
AES declares 99, carries a CRC of nought and a 0x9901 field. Getting either
backwards produces a file that opens and then fails on something that sounds
like the user's fault.

The cost is named rather than hidden: ZipCrypto puts the high byte of the
plaintext CRC in its header, so the contents have to be known before the first
byte of the entry goes out - and the contents arrive as a stream. Each entry is
therefore generated twice, once to be checksummed and once to be encrypted.
Twice the processor and not a byte more memory, because buffering the entry
would break the guard that says a generator does not hold a whole file.

Three guards stopped carrying a hand written list of methods and read the
registry instead. A list in a test is one somebody has to remember on the day a
fourth scheme arrives, and this was that day.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 68f73e0 into main Sep 1, 2026
18 checks passed
@donislawdev
donislawdev deleted the format/archives-pad-and-lock branch September 1, 2026 11:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant