format: archives pad where Go can read them, and can be locked with the old scheme too - #31
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits. One fixes a fault nobody knew about, the other adds the lock that
finds faults in other people's code.
A
.tar.gzpads where Go can read itBreaking. 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/gzipreads a header comment into a fixed buffer: 511 bytes ittakes, 512 it refuses. This format padded through that comment up to four
kilobytes, so:
The message Go gives is
gzip: invalid header, which reads like a corrupt filerather 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
remeasuredlist with themeasurement. 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 ratherthan for what it protects, measured on our own output:
ZipFiledoesLength=8192, hands backff c7 04 3ewhere the file holdstfg - txt- ciphertext as data, and never a word that the entry was encryptedInvalidDataExceptionAn 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.mdsection 4.12 has beendescribing 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-256is the one to use when the point is that the contents arehard to read.
The cipher was checked against somebody else's file before it was used
It went into
tools/probes/zipcryptobefore the generator, pointed at anarchive 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.
TestALockedEntryDeclaresTheShapeItsSchemeRequiresnow asks both schemes inboth directions: ZipCrypto stores, carries the real plaintext CRC and no extra
field. AES declares 99, carries a CRC of nought and a
0x9901field.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/targzpaddingpriced the padding change and is the reason theobvious 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.
TestEveryArchiveThisToolWritesCanBeReadByTheStandardLibraryreads 1762archives across both containers with the standard library.
one and none at all, for all four schemes, and the extracted contents are
compared against the same archive built unlocked.
read the registry instead.
preflight --quick: 11 of11, including
staticcheck,lintandfidelity.