M
Muhammedendl
Guest
Take a folder, compress it into a password-protected ZIP, and send it to someone.
Now open that archive without the password.
You'll get this:
The contents are encrypted. Nobody can read a single byte of those files.
The file list is sitting right there in plaintext.
I found this out while building encrypted export into ToFolder, a local-only photo and document app I shipped this summer. I had spent a week getting AES-256 working, felt very good about myself, opened the result on my desktop to admire it, and saw every filename listed neatly in 7-Zip without being asked for anything.
It's the format, not the tool.
In a ZIP archive, encryption covers entry data. The central directory — filenames, sizes, timestamps, the count of entries — stays readable, because that's how tools list an archive's contents without decrypting it first.
For software distribution, that's a perfectly sensible design. For privacy, it's a hole, because very often the filename is the secret. Nobody needs to open Passport.jpg to learn something about you. Divorce_settlement_final.pdf tells you the whole story from the directory listing.
7-Zip's own .7z format has an "encrypt file names" option. Standard ZIP does not, and standard ZIP is what every phone and every computer can open without installing anything, which was the entire point for me.
You can't encrypt a ZIP's central directory. So put nothing meaningful in it.
1. Compress the folder into an inner archive. Ordinary, unencrypted.
2. Compress that one file into an outer archive, encrypted.
Now the outer directory lists exactly one entry, with a name that says nothing. No names, no sizes, no dates, not even how many things are inside.
That's the whole idea. The rest of this article is the four things I got wrong around it.
The library I use offers three encryption methods: STANDARD, AES_128, AES_256.
STANDARD is legacy ZipCrypto, and you should never ship it. It's broken by a known-plaintext attack — if an attacker knows or can guess part of what's inside, they recover the key.
And here's the thing that makes it genuinely useless for an app like mine: an archive full of photos hands them that known plaintext for free. JPEG headers are fixed. It isn't weak encryption. It's a speed bump with a lock painted on it.
Both layers use NO_COMPRESSION, and that's not laziness.
Photos and video are already compressed. Running deflate over a JPEG achieves close to nothing and costs real CPU time — and now I'm doing it twice, once per layer. On a 1 GB folder that's minutes of a hot phone for savings you'd measure in kilobytes.
With compression off, the whole operation collapses into copying plus encryption. The archive comes out roughly the size of the folder, which is exactly what you want when the point is preserving files rather than shrinking them.
Two layers means two copies exist simultaneously. On a phone that's nearly full — which is most phones belonging to people who take a lot of photos — that's the difference between working and failing at 80%.
For a bulk export, the naive peak is three times the folder size: the staging copy, the inner archive, and the outer archive.
Deleting the staging copy the instant the inner layer swallows it brings that back down to two:
Check free space before you begin, and use the real multiplier, not the flattering one. A failure halfway through a 900 MB export leaves a corrupt file and a user who doesn't trust you any more.
Passport documents.zip appears in a chat app before anyone opens it. So the outer archive can be given a neutral name: ToFolder-2026-08-20.zip.
Except my restore flow derived the folder name from the archive filename. A neutral name would restore a folder called "ToFolder-2026-08-20", which is worse than useless.
The two layers solve this one too, and this is my favourite part of the whole design: the inner archive keeps the real name. It's invisible without the key, and restore reads it after decryption.
Hiding the name on the outside costs nothing on the inside.
Twenty characters drawn from a 32-symbol alphabet. 100 bits of entropy.
Two deliberate properties in that one line:
No look-alikes. No 0, no 1, no I, no O. This key gets read aloud over a phone call, retyped from one device to another, squinted at from a screenshot. A character that can be misread is a security problem wearing a typography costume, because the user doesn't conclude "I mistyped" — they conclude your app is broken.
Exactly 32 symbols, so a bitwise AND with 31 maps a random byte onto a symbol with zero bias. Any other alphabet size needs a modulo, and modulo over a non-power-of-two skews the distribution toward the front of the alphabet. It's a small bias. It's also completely free to avoid.
I went looking for one before adding a dependency. It isn't in React Native core. It isn't in the Expo runtime. Firebase doesn't polyfill it. crypto.getRandomValues simply does not exist.
Which leaves Math.random, and in Hermes that's seeded from the clock. A 100-bit key generated from a clock-seeded PRNG is guessable inside a narrow window no matter how impressive it looks on screen. That isn't security, it's the performance of security, which is worse than nothing because you'll trust it.
So I added expo-crypto and kept the generator itself pure by injecting the byte source:
The logic stays unit-testable without a native module, and there's exactly one file in the project that touches the system RNG.
The generator produced ABCDE23456FGHJK789MN.
The screen displayed and copied ABCDE-23456-FGHJK-789MN. With dashes, because grouped characters are far easier to read and retype, and I was pleased with that detail.
My import normalises dashes away before comparing. So archives opened perfectly in my own app — and 7-Zip, WinRAR and iOS all rejected the key the user had just copied to their clipboard.
Think about what that actually means. Someone receives my archive. They have the file. They have a key that looks completely correct. They paste it and are told it's wrong. There is no error message I could write that makes that experience acceptable.
The entire promise of the format is: this is a standard ZIP, any computer can open it, you are not locked into my app. That promise was broken for a week and every single test was green, because my tests only ever exercised my own import path.
The displayed form is now the password itself. Whatever the user can see and copy is literally what opens the file. There is no second, hidden representation, because the moment there are two representations one of them will eventually be wrong.
Trying to unzip an encrypted archive without a password fails in a way that looks exactly like corruption. Your user gets "invalid archive" about a file that is perfectly intact.
Detect first, then ask. And distinguish a malformed key from a valid key for a different archive — telling someone "wrong key" when they actually pasted a truncated one sends them hunting for a typo that isn't there.
The key is shown before the share sheet opens, not after. Once the system share sheet takes over, the user may go to WhatsApp and simply never come back. If the key were shown afterwards, they'd have sent an archive nobody on earth can open.
The app stores the key nowhere. Not in a database, not in a file, not in the keychain. It exists on that screen, once. Keeping a copy would mean the key and the archive live on the same device, which defeats the entire point of encrypting the archive in the first place. The screen says so plainly: lose this and the archive is gone.
And the key never travels in the same message as the file. The UI nudges you to send it through a different app, because one compromised chat thread should not contain both the lock and its key.
Be precise with your users about the boundary, because a security promise you break is far worse than one you never made.
Not re-sharing. Whoever has the file and the key can forward both. Offline, there is no revocation, no expiry, no open counter — any "expires in 7 days" you implement locally is enforced by a clock the attacker owns.
Not screenshots. Anything a person can see, they can capture.
Not a determined attacker holding your device. They extract the key at decryption time.
What it does protect is the archive in transit: through a messaging app, on a cloud drive, in a forwarded chat, on a lost laptop. An intercepted file reveals nothing. Not its contents, not its names, not even how many things are inside.
That's a real, specific guarantee. It's worth stating exactly, instead of selling it as something larger and being wrong.
Here's the test that actually matters, and it takes thirty seconds:
Export a folder with encryption on. Move the archive to a desktop. Open it in 7-Zip without entering the key.
If you see filenames, you've shipped the hole. If you see one meaningless entry and nothing else, it works.
I ran that before I believed my own tests. You should too.
Now open that archive without the password.
You'll get this:
Code:
Passport.jpg 2.4 MB 2026-08-14
Contract_signed.pdf 180 KB 2026-08-12
Bank_statement_July.pdf 95 KB 2026-08-01
The contents are encrypted. Nobody can read a single byte of those files.
The file list is sitting right there in plaintext.
I found this out while building encrypted export into ToFolder, a local-only photo and document app I shipped this summer. I had spent a week getting AES-256 working, felt very good about myself, opened the result on my desktop to admire it, and saw every filename listed neatly in 7-Zip without being asked for anything.
Why it happens
It's the format, not the tool.
In a ZIP archive, encryption covers entry data. The central directory — filenames, sizes, timestamps, the count of entries — stays readable, because that's how tools list an archive's contents without decrypting it first.
For software distribution, that's a perfectly sensible design. For privacy, it's a hole, because very often the filename is the secret. Nobody needs to open Passport.jpg to learn something about you. Divorce_settlement_final.pdf tells you the whole story from the directory listing.
7-Zip's own .7z format has an "encrypt file names" option. Standard ZIP does not, and standard ZIP is what every phone and every computer can open without installing anything, which was the entire point for me.
The fix is embarrassingly simple
You can't encrypt a ZIP's central directory. So put nothing meaningful in it.
1. Compress the folder into an inner archive. Ordinary, unencrypted.
2. Compress that one file into an outer archive, encrypted.
Now the outer directory lists exactly one entry, with a name that says nothing. No names, no sizes, no dates, not even how many things are inside.
Code:
async function sealArchive(staging, source, innerName, target, password) {
const sealed = staging + 'sealed/';
await FileSystem.makeDirectoryAsync(sealed, { intermediates: true });
await zip(stripScheme(source), stripScheme(sealed + innerName), NO_COMPRESSION);
const written = await zipWithPassword(
stripScheme(sealed),
stripScheme(target),
password,
EncryptionMethods.AES_256,
NO_COMPRESSION
);
// that inner file is a complete UNENCRYPTED copy of everything.
// it does not get to exist one second longer than it has to.
await FileSystem.deleteAsync(sealed, { idempotent: true });
return written;
}
That's the whole idea. The rest of this article is the four things I got wrong around it.
AES-256, and never the other one
The library I use offers three encryption methods: STANDARD, AES_128, AES_256.
STANDARD is legacy ZipCrypto, and you should never ship it. It's broken by a known-plaintext attack — if an attacker knows or can guess part of what's inside, they recover the key.
And here's the thing that makes it genuinely useless for an app like mine: an archive full of photos hands them that known plaintext for free. JPEG headers are fixed. It isn't weak encryption. It's a speed bump with a lock painted on it.
Compressing twice is worse than compressing once
Both layers use NO_COMPRESSION, and that's not laziness.
Photos and video are already compressed. Running deflate over a JPEG achieves close to nothing and costs real CPU time — and now I'm doing it twice, once per layer. On a 1 GB folder that's minutes of a hot phone for savings you'd measure in kilobytes.
With compression off, the whole operation collapses into copying plus encryption. The archive comes out roughly the size of the folder, which is exactly what you want when the point is preserving files rather than shrinking them.
The number that actually breaks things: peak disk
Two layers means two copies exist simultaneously. On a phone that's nearly full — which is most phones belonging to people who take a lot of photos — that's the difference between working and failing at 80%.
For a bulk export, the naive peak is three times the folder size: the staging copy, the inner archive, and the outer archive.
Deleting the staging copy the instant the inner layer swallows it brings that back down to two:
Code:
await zip(source, innerPath, NO_COMPRESSION);
if (disposeSource) await FileSystem.deleteAsync(source, { idempotent: true });
await zipWithPassword(sealedDir, target, password, AES_256, NO_COMPRESSION);
Check free space before you begin, and use the real multiplier, not the flattering one. A failure halfway through a 900 MB export leaves a corrupt file and a user who doesn't trust you any more.
Hiding the filename without losing the folder name
Passport documents.zip appears in a chat app before anyone opens it. So the outer archive can be given a neutral name: ToFolder-2026-08-20.zip.
Except my restore flow derived the folder name from the archive filename. A neutral name would restore a folder called "ToFolder-2026-08-20", which is worse than useless.
The two layers solve this one too, and this is my favourite part of the whole design: the inner archive keeps the real name. It's invisible without the key, and restore reads it after decryption.
Hiding the name on the outside costs nothing on the inside.
The key
Twenty characters drawn from a 32-symbol alphabet. 100 bits of entropy.
Code:
export const KEY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
Two deliberate properties in that one line:
No look-alikes. No 0, no 1, no I, no O. This key gets read aloud over a phone call, retyped from one device to another, squinted at from a screenshot. A character that can be misread is a security problem wearing a typography costume, because the user doesn't conclude "I mistyped" — they conclude your app is broken.
Exactly 32 symbols, so a bitwise AND with 31 maps a random byte onto a symbol with zero bias. Any other alphabet size needs a modulo, and modulo over a non-power-of-two skews the distribution toward the front of the alphabet. It's a small bias. It's also completely free to avoid.
Code:
export function generateArchiveKey(randomBytes) {
const bytes = randomBytes(KEY_LENGTH);
let key = '';
for (let i = 0; i < KEY_LENGTH; i++) key += KEY_ALPHABET[bytes[i] & 31];
return key;
}
React Native doesn't have a secure random number generator
I went looking for one before adding a dependency. It isn't in React Native core. It isn't in the Expo runtime. Firebase doesn't polyfill it. crypto.getRandomValues simply does not exist.
Which leaves Math.random, and in Hermes that's seeded from the clock. A 100-bit key generated from a clock-seeded PRNG is guessable inside a narrow window no matter how impressive it looks on screen. That isn't security, it's the performance of security, which is worse than nothing because you'll trust it.
So I added expo-crypto and kept the generator itself pure by injecting the byte source:
Code:
generateArchiveKey(secureRandomBytes) // production
generateArchiveKey(() => Uint8Array.from([0, 1, 2])) // tests
The logic stays unit-testable without a native module, and there's exactly one file in the project that touches the system RNG.
The bug that made every bit of this worthless
The generator produced ABCDE23456FGHJK789MN.
The screen displayed and copied ABCDE-23456-FGHJK-789MN. With dashes, because grouped characters are far easier to read and retype, and I was pleased with that detail.
My import normalises dashes away before comparing. So archives opened perfectly in my own app — and 7-Zip, WinRAR and iOS all rejected the key the user had just copied to their clipboard.
Think about what that actually means. Someone receives my archive. They have the file. They have a key that looks completely correct. They paste it and are told it's wrong. There is no error message I could write that makes that experience acceptable.
The entire promise of the format is: this is a standard ZIP, any computer can open it, you are not locked into my app. That promise was broken for a week and every single test was green, because my tests only ever exercised my own import path.
The displayed form is now the password itself. Whatever the user can see and copy is literally what opens the file. There is no second, hidden representation, because the moment there are two representations one of them will eventually be wrong.
Check before you open
Trying to unzip an encrypted archive without a password fails in a way that looks exactly like corruption. Your user gets "invalid archive" about a file that is perfectly intact.
Code:
const encrypted = await isPasswordProtected(stripScheme(archiveUri));
if (encrypted && !options.password) throw new PasswordRequiredError();
Detect first, then ask. And distinguish a malformed key from a valid key for a different archive — telling someone "wrong key" when they actually pasted a truncated one sends them hunting for a typo that isn't there.
Two design decisions that aren't code
The key is shown before the share sheet opens, not after. Once the system share sheet takes over, the user may go to WhatsApp and simply never come back. If the key were shown afterwards, they'd have sent an archive nobody on earth can open.
The app stores the key nowhere. Not in a database, not in a file, not in the keychain. It exists on that screen, once. Keeping a copy would mean the key and the archive live on the same device, which defeats the entire point of encrypting the archive in the first place. The screen says so plainly: lose this and the archive is gone.
And the key never travels in the same message as the file. The UI nudges you to send it through a different app, because one compromised chat thread should not contain both the lock and its key.
What this does not protect against
Be precise with your users about the boundary, because a security promise you break is far worse than one you never made.
Not re-sharing. Whoever has the file and the key can forward both. Offline, there is no revocation, no expiry, no open counter — any "expires in 7 days" you implement locally is enforced by a clock the attacker owns.
Not screenshots. Anything a person can see, they can capture.
Not a determined attacker holding your device. They extract the key at decryption time.
What it does protect is the archive in transit: through a messaging app, on a cloud drive, in a forwarded chat, on a lost laptop. An intercepted file reveals nothing. Not its contents, not its names, not even how many things are inside.
That's a real, specific guarantee. It's worth stating exactly, instead of selling it as something larger and being wrong.
Proof, not assertion
Here's the test that actually matters, and it takes thirty seconds:
Export a folder with encryption on. Move the archive to a desktop. Open it in 7-Zip without entering the key.
If you see filenames, you've shipped the hole. If you see one meaningless entry and nothing else, it works.
I ran that before I believed my own tests. You should too.