Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,29 @@ because it turns other people's test suites red.

### Breaking

- **A generated `.tar.gz` has different bytes, because a lot of them could
not be opened by a Go program.** Sizes are unchanged, every size that
worked before still works, and every reader that took these files still
takes them. What moved is where the padding sits inside the header.

The padding used to ride in the gzip header comment. Go's own
`compress/gzip` reads that field into a fixed buffer and refuses a comment
of 512 bytes or more, so **4134 of the 11 260 reachable sizes produced an
archive no Go program could open** - and the message it gives,
`gzip: invalid header`, reads like a corrupt file rather than like a field
the reader will not take. 7-Zip, GNU tar, bsdtar, Python and node all took
those files without a word, which is why it was not noticed sooner.

The padding now rides in the gzip extra field, which Go reads to the end
of. After the change: 11 260 sizes reachable, none unreachable, none
unreadable.

**There is no way back to the old bytes**, and that is the difference
between this and the other two entries here. The old bytes are the ones a
Go program cannot read, so keeping a switch for them would be keeping a
switch for the fault. A suite pinning `.tar.gz` hashes will go red once
and then stay green.

- **A generated log now advances through time, so its bytes are different.**
Every entry used to carry the same instant. Ten thousand requests all landing
at one moment is not a log anybody can test a time window, a rate alert or a
Expand Down Expand Up @@ -47,6 +70,14 @@ because it turns other people's test suites red.

### Added

- **A zip can be locked with ZipCrypto, the old scheme.** `--set encryption=zipcrypto`.

It is here for what it does to a reader rather than for what it protects. Measured: .NET's own `ZipFile` opens one of these, reports the entry at its true length, hands back a stream and fills it with the ENCRYPTED bytes - and never says the entry was encrypted at all. 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.

So this is the fixture for finding out whether something in a pipeline waves an encrypted archive through.

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

- **A zip can be locked with a password.**

tfg generate --format zip --size 30kb --set entries=3 \
Expand Down
8 changes: 5 additions & 3 deletions internal/format/archive/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const (
// The encryption methods, spelled the way a recipe writes them.
const (
NoEncryption = "none"
ZipCrypto = "zipcrypto"
AES128 = "aes-128"
AES192 = "aes-192"
AES256 = "aes-256"
Expand Down Expand Up @@ -162,10 +163,11 @@ var axes = map[string]format.Property{
Encryption: {
Name: Encryption, Kind: format.PropertyChoice,
// Sorted, because a closed set has one order on every surface.
Choices: []string{AES128, AES192, AES256, NoEncryption},
Choices: []string{AES128, AES192, AES256, NoEncryption, ZipCrypto},
Default: NoEncryption,
Detail: "How the archive is locked. This is the WinZip AES scheme, which 7-Zip and WinZip open " +
"and some other readers cannot open at all - .NET lists the files and then fails on reading one.",
Detail: "How the archive is locked. AES is the WinZip scheme, and some readers cannot open it " +
"at all. ZipCrypto is the old one every reader opens and nothing modern trusts, and some " +
"of them hand back the encrypted bytes without saying so.",
},
}

Expand Down
52 changes: 50 additions & 2 deletions internal/format/archive/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ const (
authLen = 10
iterations = 1000

// zipStore is a stored entry, and winZipAES is the number an AES entry
// declares instead. The second is not a compression at all: the real method
// sits in the 0x9901 field beside it, and this build always stores.
zipStore = 0
winZipAES = 99

// aesExtraLen is the 0x9901 field: two bytes of id, two of length and
// seven of body. It is written into the local header AND the central
// directory, so an entry pays for it twice.
Expand All @@ -64,6 +70,22 @@ type Lock struct {
// On says whether anything is encrypted.
func (l Lock) On() bool { return l.Method != "" && l.Method != NoEncryption }

// NeedsPlaintextCRC says whether an entry cannot be started until its
// contents are known.
//
// True for ZipCrypto and false for AES, and the difference is measured rather
// than assumed. Read out of what 7-Zip writes: a ZipCrypto entry carries the
// real CRC of the plaintext and puts its high byte in the header, so a reader
// can reject a wrong password without decrypting anything. An AE-2 entry
// carries a CRC of zero, because its authentication code does that job.
//
// The cost falls on the caller and it is real: an entry that needs this is
// generated twice, once to be counted and once to be encrypted. Twice the
// processor and not a byte more memory, which is the trade this project takes
// every time - holding the file to hash it would break the guard that says a
// generator does not.
func (l Lock) NeedsPlaintextCRC() bool { return l.Method == ZipCrypto }

// keyLen is the AES key in bytes, and the salt is half of it. Zero for an
// archive that is not locked with AES.
func (l Lock) keyLen() int {
Expand Down Expand Up @@ -98,6 +120,7 @@ func (l Lock) strength() byte {
// Measured, and it agrees from two directions - the size of the whole file and
// the compressed size field in the local header:
//
// ZipCrypto +12 (eleven bytes that vary and one check byte)
// AES-128 +20 (8 salt, 2 verifier, 10 authentication)
// AES-192 +24
// AES-256 +28
Expand All @@ -106,12 +129,34 @@ func (l Lock) strength() byte {
// headers are written for real during the counting pass, so the writer counts
// those eleven bytes twice over on its own.
func (l Lock) EntryOverhead() int64 {
if !l.On() {
switch {
case !l.On():
return 0
case l.Method == ZipCrypto:
return zipCryptoHeader
}
return int64(l.saltLen() + pwvLen + authLen)
}

// ZipMethod is the compression method the entry declares. Named for what it
// answers rather than for the field it reads, because Method is that field.
//
// AES entries declare 99, which is not a compression at all - the real
// method sits in the 0x9901 field beside it. ZipCrypto declares what it
// really is, because it changes nothing about how the bytes are stored, it
// only puts twelve bytes in front of them and scrambles what follows.
//
// Getting this wrong is quiet. An entry declaring 99 with no 0x9901 field
// beside it gets past the check byte and fails on the checksum, and 7-Zip
// reports "Data Error in encrypted file. Wrong password?" - which points at
// the password, the one thing that was right.
func (l Lock) ZipMethod() uint16 {
if l.keyLen() == 0 {
return zipStore
}
return winZipAES
}

// Extra is the 0x9901 field an AES entry carries, and nil for anything else.
func (l Lock) Extra() []byte {
if l.keyLen() == 0 {
Expand Down Expand Up @@ -185,7 +230,10 @@ func ReadLock(id string, props map[string]string) (Lock, error) {
// would give two runs of one recipe different bytes, which is untouchable rule
// 3 - and the same recipe producing the same file is more of the product here
// than the encryption is.
func (l Lock) NewEntryWriter(w io.Writer, seed uint64, index int) (io.WriteCloser, error) {
func (l Lock) NewEntryWriter(w io.Writer, seed uint64, index int, crc uint32) (io.WriteCloser, error) {
if l.Method == ZipCrypto {
return l.newZipCryptoWriter(w, seed, index, crc)
}
if l.keyLen() == 0 {
return nil, fmt.Errorf("archive: %q is not an encryption this build can write", l.Method)
}
Expand Down
126 changes: 126 additions & 0 deletions internal/format/archive/zipcrypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package archive

import (
"hash/crc32"
"io"

"github.com/donislawdev/TestingFilesGenerator/internal/core"
)

// The old ZIP encryption, the one everything opens and nothing modern trusts.
//
// It is here for what it does to a reader rather than for what it protects.
// Measured on 2026-09-01: .NET's own ZipFile opens a ZipCrypto archive, reports
// the entry at its true length, hands back a stream and fills it with
// CIPHERTEXT - 'ea 59 89 8e' where the file holds 'AAAA' - and never says the
// entry was encrypted at all. 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.
//
// So this is the fixture that finds a real class of fault, and it is the one
// the presets have been waiting for: PRESETS.md section 4.12 names "an archive
// with a password waved through without warning" as a thing to catch.
//
// The cipher itself is not ours and was not taken on trust. It was written into
// a probe first, pointed at an archive 7-Zip produced, and asked to decrypt it -
// the check byte, the plaintext CRC and the bytes themselves all came back
// right. tools/probes/zipcrypto, and it stays runnable, because a keystream
// that is subtly wrong produces a file some readers still accept.

const (
// The three keys PKWARE starts from and the multiplier it steps them with.
// Constants of the scheme rather than choices of ours.
key0Init = 305419896
key1Init = 591751049
key2Init = 878082192
multiplier = 134775813

// zipCryptoHeader is the twelve bytes that precede an entry's data: eleven
// that vary and one that lets a reader reject a wrong password without
// decrypting anything else. It is the whole of what this scheme adds, which
// is why the overhead is a constant.
zipCryptoHeader = 12
)

var crcTable = crc32.MakeTable(crc32.IEEE)

// pkware is the stream cipher, keyed by a password and then by every byte of
// plaintext that passes through it.
//
// The plaintext updates the keys in both directions, which is why encrypting
// and decrypting are two functions here rather than one - a stream cipher that
// is its own inverse would not need the distinction, and this one is not.
type pkware struct{ k0, k1, k2 uint32 }

func newPKWARE(password string) *pkware {
c := &pkware{k0: key0Init, k1: key1Init, k2: key2Init}
for i := 0; i < len(password); i++ {
c.update(password[i])
}
return c
}

func (c *pkware) update(p byte) {
c.k0 = crcTable[(c.k0^uint32(p))&0xff] ^ (c.k0 >> 8)
c.k1 += c.k0 & 0xff
c.k1 = c.k1*multiplier + 1
c.k2 = crcTable[(c.k2^(c.k1>>24))&0xff] ^ (c.k2 >> 8)
}

func (c *pkware) keyByte() byte {
t := uint16(c.k2|2) & 0xffff
return byte((t * (t ^ 1)) >> 8)
}

func (c *pkware) encrypt(p byte) byte {
x := p ^ c.keyByte()
c.update(p)
return x
}

// zipCryptoWriter encrypts on the way through, header first.
type zipCryptoWriter struct {
out io.Writer
c *pkware
}

// newZipCryptoWriter starts an entry, writing the twelve byte header before
// anything else.
//
// The eleven bytes that vary come from the run seed and never from crypto/rand,
// for the reason every other draw in this tool avoids it: two runs of one
// recipe have to give one file. The twelfth is the high byte of the plaintext
// CRC, and it is why this scheme needs the contents known before the first byte
// goes out - the whole reason a ZipCrypto entry is generated twice.
func (l Lock) newZipCryptoWriter(w io.Writer, seed uint64, index int, crc uint32) (io.WriteCloser, error) {
c := newPKWARE(l.Password)
rng := core.NewRand(core.FileSeed(seed, index))

header := make([]byte, zipCryptoHeader)
for i := range header[:zipCryptoHeader-1] {
header[i] = byte(rng.Uint32())
}
header[zipCryptoHeader-1] = byte(crc >> 24)
for i := range header {
header[i] = c.encrypt(header[i])
}
if _, err := w.Write(header); err != nil {
return nil, err
}
return &zipCryptoWriter{out: w, c: c}, nil
}

func (z *zipCryptoWriter) Write(p []byte) (int, error) {
out := make([]byte, len(p))
for i := range p {
out[i] = z.c.encrypt(p[i])
}
if _, err := z.out.Write(out); err != nil {
return 0, err
}
return len(p), nil
}

// Close has nothing to finish. ZipCrypto signs nothing - the entry's own CRC is
// what a reader checks, after decrypting, if it checks at all.
func (z *zipCryptoWriter) Close() error { return nil }
Loading
Loading