Kotlin Multiplatform ZIP and GZIP library for JVM, iOS, macOS, Linux, Windows, Kotlin/JS, and Kotlin/Wasm (wasmJs) targets, with encryption support.
Provides ByteArrayInputStream, ByteArrayOutputStream, ZipInputStream, ZipOutputStream, GzipInputStream, and GzipOutputStream with a common API across platforms. Supports reading and writing encrypted ZIP archives:
- WinZip AES (AES-128/192/256, AE-1 and AE-2 formats) — strong encryption, compatible with 7-Zip, WinRAR, etc.
- PKWare traditional (ZipCrypto) — legacy encryption compatible with all ZIP tools including macOS
zipand Windows Explorer
All ZIP, GZIP, and crypto logic is implemented in common Kotlin. Platform-specific code is limited to thin wrappers around native primitives: java.util.zip + javax.crypto on JVM, platform.zlib + CommonCrypto on Apple targets, platform.zlib + a pure-Kotlin AES/HMAC/PBKDF2 fallback on Linux and Windows native targets, pako (MIT) + the same pure-Kotlin crypto on js and wasmJs.
| Artifact | Description |
|---|---|
no.synth:kmp-zip |
Core I/O, ZIP, and GZIP streams |
no.synth:kmp-zip-kotlinx |
kotlinx-io Source/Sink adapters (both directions) for the core streams |
no.synth:kmp-zip-okio |
OkIO BufferedSource/BufferedSink/Source/Sink adapters (both directions) for the core streams |
kmp-zip-cli |
Command-line tool for ZIP/GZIP operations — ships as standalone native binaries on macOS / Linux / Windows, with a JVM fallback. Not published to Maven Central. |
- JVM (Java 8 baseline, class version 52; also consumable from Android projects)
- iosArm64, iosSimulatorArm64
- macosArm64, macosX64
- linuxX64, linuxArm64
- mingwX64
- js (browser, Node 20+) — see JS notes below
- wasmJs (browser, Node 20+) — see JS notes below
The published JVM artifacts are compiled with jvmTarget = 1.8 and -Xjdk-release=1.8, so they emit class version 52 and link only against Java 8 APIs, and the published Gradle metadata declares org.gradle.jvm.version = 8. CI checks the class version of every published jar, runs the JVM test suites on a real JDK 8, and resolves the published artifact from a Java 8 consumer build.
Both JS-hosted targets ship the same library API as every other target, and share one implementation — only the ByteArray <-> Uint8Array marshalling is written per target. There is no kmp-zip-cli for either, and no zipTo/unzipFrom filesystem helpers in the adapter modules — work with ByteArray and the stream classes, and wire any file I/O on the host side.
A working browser sample lives in samples/browser-demo — a single page that picks a .gz or .zip file from disk, runs it through GzipInputStream / ZipInputStream in the browser tab, and prompts for a password if the archive is encrypted. It builds from one source set for both targets, so pick either:
./gradlew :samples:browser-demo:jsBrowserDevelopmentRun
./gradlew :samples:browser-demo:wasmJsBrowserDevelopmentRunThat builds the bundle, starts a webpack dev server on http://localhost:8080, and opens it. The sample also ships jsNodeTest / wasmJsNodeTest harnesses that run every archive in kmp-zip/src/commonTest/resources/testdata/ through the sample's pipeline — see samples/browser-demo/README.md.
- pako runtime dependency. Deflate/inflate is delegated to pako 2.1.0, pinned exactly. Kotlin's js and wasmJs builds pick it up automatically — the kmp-zip project's
kotlin-js-store/yarn.lockandkotlin-js-store/wasm/yarn.lockrecord the tarball SHA-512 (sha512-w+eufiZ1...). Downstream consumers manage their own lockfile; commit yours, and note that KGP fails the build on lockfile drift by default (yarnLockMismatchReport), refreshed withkotlinUpgradeYarnLock. pako adds ~45 KB minified (~14 KB gzipped) to a bundle and is not effectively tree-shakeable. fileSeekableSourceis Node-only, and onjsit breaks browser bundling. It binds Node'snode:fs, which a bundler resolves statically: onjs, merely referencingfileSeekableSourcefrom code reachable in a browser bundle fails the build withCan't resolve 'node:fs'. Keep it behind a Node-only entry point, or useByteArraySeekableSourcein the browser. Unreachable references are dropped cleanly on both targets.Crypto.randomBytesrequires Web Crypto. CallsglobalThis.crypto.getRandomValues, which is available in any browser context (HTTPS or plainhttp://) and Node 20+. If the runtime doesn't expose Web Crypto — sandboxed JS realms, browsers with Web Crypto disabled by policy — the call throwsIllegalStateExceptionnaming the likely cause.- Long-running compression blocks the UI thread. pako is synchronous; deflating a multi-MB archive on the main browser thread can stall rendering for hundreds of ms. For anything beyond small archives, run kmp-zip in a
DedicatedWorker. Neither target depends onwindow/document, so a worker works without extra setup.
Don't encrypt long-lived archives in a browser tab. Use the JVM/Apple targets or do encryption server-side. The pure-Kotlin AES used on Linux, Windows, js, and wasmJs is table-based and leaks key bits via cache-timing on shared hardware. In a browser, same-origin attacker JS runs in the same renderer process; an in-process leak there recovers the AES key directly — strictly worse than the PBKDF2 brute-force baseline that protects an archive at rest. (PBKDF2 forces an attacker with only the encrypted bytes to brute-force the password; an in-process AES leak skips that step entirely.) JVM and Apple targets use platform AES (javax.crypto / CommonCrypto) and are not affected.
Decrypting attacker-supplied archives with a user-typed password in the browser is fine — the password is already there. PBKDF2 and HMAC-SHA1 are not table-based and have no known cache-timing leakage in the pure-Kotlin impl.
Decompression bombs. kmp-zip does not enforce an upper bound on inflated output; pako has no maxOutputLength. A 1 KB compressed stream can inflate to 1 GB. Cap untrusted ZIP reads explicitly:
val MAX_BYTES = 100L * 1024 * 1024 // 100 MB
val out = ByteArray(8192)
var total = 0L
while (true) {
val n = zis.read(out)
if (n == -1) break
total += n
if (total > MAX_BYTES) error("Entry exceeds size limit")
sink.write(out, 0, n)
}The ZipEntry.size field declares the uncompressed size up front and can be checked before reading; the running counter handles archives that lie about declared size. js and wasmJs are the most exposed targets — browser-side ZIP reading of untrusted input is a liability and should be validated accordingly.
Published on Maven Central. No special repository configuration needed.
kotlin {
// Add the targets you need, including the JS-hosted ones:
// js { browser(); nodejs() }
// wasmJs { browser(); nodejs() }
sourceSets {
commonMain {
dependencies {
implementation("no.synth:kmp-zip:0.15.0")
// Optional: kotlinx-io adapters
implementation("no.synth:kmp-zip-kotlinx:0.15.0")
// Optional: OkIO adapters
implementation("no.synth:kmp-zip-okio:0.15.0")
}
}
}
}| Type | Description |
|---|---|
InputStream |
Abstract class — read(), read(ByteArray, off, len), available(), skip(), close(), etc. |
OutputStream |
Abstract class — write(Int), write(ByteArray, off, len), flush(), close() |
ByteArrayInputStream |
Reads from a ByteArray. Full Java-compatible API. |
ByteArrayOutputStream |
Auto-growing buffer with toByteArray(), size(), reset(), writeTo() |
InputStream.readBytes() |
Extension that reads all remaining bytes |
SeekableSource |
Random-access, read-only byte source — positional read(position, into, off, len) + size. Used by ZipFile. |
ByteArraySeekableSource(ByteArray) |
In-memory SeekableSource; works on every target including js/wasmJs in the browser |
fileSeekableSource(path) |
File-backed SeekableSource that reads lazily by position. JVM/Apple/Linux/Windows native and js/wasmJs on Node (not browser). Windows native is capped at 2 GB (32-bit file offsets). |
| Type | Description |
|---|---|
ZipInputStream(InputStream, password?) |
Reads ZIP entries — nextEntry, closeEntry(), read(), readBytes(). Pass a password (ByteArray or String) to decrypt encrypted entries (auto-detects AES or legacy). |
ZipInputStream(ByteArray, password?) |
Convenience factory |
ZipFile(SeekableSource, password?) |
Random-access reader — parses the central directory, then getEntry(name) / entries / getInputStream(entry) seek straight to one entry without streaming the whole archive. Decrypts AES and legacy entries like ZipInputStream. No ZIP64. |
ZipFile(ByteArray, password?) |
Convenience factory |
ZipOutputStream(OutputStream, password?, encryption?, aesStrength?) |
Writes ZIP entries — putNextEntry(), closeEntry(), write(), finish(), setMethod(), setLevel(). Pass a password to encrypt all entries. |
ZipEntry |
Entry metadata — name, size, compressedSize, crc, method, isDirectory, time, comment, extra |
isZip(ByteArray) |
Four-byte sniff: true if the data starts with a local-file-header or end-of-central-directory signature. Not validation. |
ZipConstants |
STORED = 0, DEFLATED = 8 |
ZipEncryption |
AES (default, strong), LEGACY (PKWare traditional, for compatibility) |
AesStrength |
AES_128, AES_192, AES_256 (default) |
| Type | Description |
|---|---|
Crypto.pbkdf2(password, salt, iterations, keyLengthBytes) |
PBKDF2 key derivation with HMAC-SHA1 |
Crypto.hmacSha1(key, data) |
HMAC-SHA1 message authentication |
Crypto.crc32(data) |
CRC-32 checksum (pure Kotlin, no platform dependency) |
Crypto.randomBytes(size) |
Cryptographically secure random bytes |
| Type | Description |
|---|---|
GzipInputStream(InputStream) |
Decompresses a GZIP stream — read(), available(), close() |
GzipInputStream(ByteArray) |
Convenience factory |
GzipOutputStream(OutputStream) |
Compresses data in GZIP format — write(), finish(), flush(), close() |
isGzip(ByteArray) |
Two-byte sniff: true if the data starts with the GZIP magic 0x1f 0x8b. Not validation. |
| Type | Description |
|---|---|
SourceInputStream(Source) |
Wraps a kotlinx-io Source as an InputStream |
SinkOutputStream(Sink) |
Wraps a kotlinx-io Sink as an OutputStream |
InputStreamSource(InputStream) |
Wraps an InputStream as a kotlinx-io RawSource |
OutputStreamSink(OutputStream) |
Wraps an OutputStream as a kotlinx-io RawSink |
Source.asInputStream() |
Extension shorthand |
Sink.asOutputStream() |
Extension shorthand |
InputStream.asSource() |
Extension shorthand |
OutputStream.asSink() |
Extension shorthand |
ZipInputStream(Source) |
Factory — creates a ZipInputStream from a Source |
ZipOutputStream(Sink) |
Factory — creates a ZipOutputStream from a Sink |
GzipInputStream(Source) |
Factory — creates a GzipInputStream from a Source |
GzipOutputStream(Sink) |
Factory — creates a GzipOutputStream from a Sink |
FileSystem.zipTo(target, sources, ...) |
Suspend helper — recursively zips files/directories into target |
FileSystem.unzipFrom(archive, target, ...) |
Suspend helper — extracts archive into target, rejecting unsafe entry names |
| Type | Description |
|---|---|
SourceInputStream(BufferedSource) |
Wraps an OkIO BufferedSource as an InputStream |
SinkOutputStream(BufferedSink) |
Wraps an OkIO BufferedSink as an OutputStream |
InputStreamSource(InputStream) |
Wraps an InputStream as an OkIO Source |
OutputStreamSink(OutputStream) |
Wraps an OutputStream as an OkIO Sink |
BufferedSource.asInputStream() |
Extension shorthand |
BufferedSink.asOutputStream() |
Extension shorthand |
InputStream.asSource() |
Extension shorthand |
OutputStream.asSink() |
Extension shorthand |
ZipInputStream(BufferedSource) |
Factory — creates a ZipInputStream from a BufferedSource |
ZipOutputStream(BufferedSink) |
Factory — creates a ZipOutputStream from a BufferedSink |
ZipFile(FileHandle, password?) |
Factory — random-access ZipFile over an okio FileHandle (open via FileSystem.openReadOnly(path)) |
FileHandle.asSeekableSource() |
Adapts an okio FileHandle to a SeekableSource |
GzipInputStream(BufferedSource) |
Factory — creates a GzipInputStream from a BufferedSource |
GzipOutputStream(BufferedSink) |
Factory — creates a GzipOutputStream from a BufferedSink |
FileSystem.zipTo(target, sources, ...) |
Suspend helper — recursively zips files/directories into target |
FileSystem.unzipFrom(archive, target, ...) |
Suspend helper — extracts archive into target, rejecting unsafe entry names |
ZipInputStream(zipBytes).use { zis ->
while (true) {
val entry = zis.nextEntry ?: break
println("${entry.name}: ${zis.readBytes().decodeToString()}")
}
}ZipInputStream walks entries front-to-back. When you only need a specific entry
from a large archive — e.g. read the database now, defer the media — use ZipFile,
which reads the central directory and seeks straight to the entry you ask for.
import no.synth.kmpzip.io.fileSeekableSource
// File-backed: only the central directory and the entry you read are touched —
// the archive is never fully loaded into memory.
ZipFile(fileSeekableSource("/path/to/backup.zip"), password = "secret").use { zip ->
val db = zip.getEntry("diary.sqlite") ?: error("missing db")
val bytes = zip.getInputStream(db).use { it.readBytes() }
// ...import bytes now; extract media entries later, in any order.
}In a browser (js or wasmJs) there is no synchronous random file access — back ZipFile
with ByteArraySeekableSource(bytes) (or just ZipFile(bytes)) instead.
val buf = ByteArrayOutputStream()
ZipOutputStream(buf).use { zos ->
zos.putNextEntry(ZipEntry("hello.txt"))
zos.write("Hello, world!".encodeToByteArray())
zos.closeEntry()
}
val zipBytes = buf.toByteArray()ZipInputStream(zipBytes, password = "secret").use { zis ->
while (true) {
val entry = zis.nextEntry ?: break
println("${entry.name}: ${zis.readBytes().decodeToString()}")
}
}val buf = ByteArrayOutputStream()
ZipOutputStream(buf, password = "secret").use { zos ->
zos.putNextEntry(ZipEntry("hello.txt"))
zos.write("Hello, encrypted world!".encodeToByteArray())
zos.closeEntry()
}
val encryptedZipBytes = buf.toByteArray()By default, entries are encrypted with AES-256 and DEFLATED compression. You can choose a different strength:
ZipOutputStream(buf, password = "secret", aesStrength = AesStrength.AES_128)For maximum compatibility with older tools (macOS Finder, Windows Explorer, unzip):
val buf = ByteArrayOutputStream()
ZipOutputStream(buf, password = "secret", encryption = ZipEncryption.LEGACY).use { zos ->
zos.putNextEntry(ZipEntry("hello.txt"))
zos.write("Hello, legacy encrypted!".encodeToByteArray())
zos.closeEntry()
}Reading works the same regardless of encryption method — ZipInputStream auto-detects AES vs legacy.
The Crypto object provides cross-platform cryptographic primitives that can be used independently of ZIP:
import no.synth.kmpzip.crypto.Crypto
// PBKDF2 key derivation
val salt = Crypto.randomBytes(16)
val key = Crypto.pbkdf2(
password = "secret".encodeToByteArray(),
salt = salt,
iterations = 100_000,
keyLengthBytes = 32,
)
// HMAC-SHA1
val mac = Crypto.hmacSha1(key, data = "message".encodeToByteArray())
// CRC-32 checksum
val checksum = Crypto.crc32("Hello".encodeToByteArray())
// Secure random
val nonce = Crypto.randomBytes(12)// Compress
val buf = ByteArrayOutputStream()
GzipOutputStream(buf).use { gzos ->
gzos.write("Hello, world!".encodeToByteArray())
}
val gzipped = buf.toByteArray()
// Decompress
val text = GzipInputStream(gzipped).use { gzis ->
gzis.readBytes().decodeToString()
}import kotlinx.io.Buffer
import no.synth.kmpzip.kotlinx.ZipInputStream
import no.synth.kmpzip.kotlinx.ZipOutputStream
val buffer = Buffer()
// Write
ZipOutputStream(buffer).use { zos ->
zos.putNextEntry(ZipEntry("hello.txt"))
zos.write("Hello from kotlinx-io!".encodeToByteArray())
zos.closeEntry()
}
// Read
ZipInputStream(buffer).use { zis ->
val entry = zis.nextEntry ?: error("Expected entry")
println("${entry.name}: ${zis.readBytes().decodeToString()}")
}import okio.Buffer
import no.synth.kmpzip.okio.ZipInputStream
import no.synth.kmpzip.okio.ZipOutputStream
val buffer = Buffer()
// Write
ZipOutputStream(buffer).use { zos ->
zos.putNextEntry(ZipEntry("hello.txt"))
zos.write("Hello from OkIO!".encodeToByteArray())
zos.closeEntry()
}
// Read
ZipInputStream(buffer).use { zis ->
val entry = zis.nextEntry ?: error("Expected entry")
println("${entry.name}: ${zis.readBytes().decodeToString()}")
}The InputStream.asSource() adapter lets you read a ZIP entry through a Source, which is useful
for streaming deserialization (e.g. kotlinx-serialization's decodeFromBufferedSource).
OkIO:
import okio.buffer
import no.synth.kmpzip.okio.ZipInputStream
import no.synth.kmpzip.okio.asSource
ZipInputStream(source).use { zis ->
val entry = zis.nextEntry
if (entry != null) {
val entrySource = zis.asSource().buffer()
// Stream directly from the ZIP entry — e.g. Json.decodeFromBufferedSource(serializer, entrySource)
println("${entry.name}: ${entrySource.readUtf8()}")
}
}kotlinx-io:
import kotlinx.io.buffered
import kotlinx.io.readString
import no.synth.kmpzip.kotlinx.ZipInputStream
import no.synth.kmpzip.kotlinx.asSource
ZipInputStream(source).use { zis ->
val entry = zis.nextEntry
if (entry != null) {
val entrySource = zis.asSource().buffered()
// Stream directly from the ZIP entry — e.g. Json.decodeFromBufferedSource(serializer, entrySource)
println("${entry.name}: ${entrySource.readString()}")
}
}The OutputStream.asSink() adapter lets you write into
a ZIP entry through a Sink, which is useful for streaming serialization (e.g. kotlinx-serialization's
encodeToSink).
OkIO:
import okio.buffer
import no.synth.kmpzip.okio.ZipOutputStream
import no.synth.kmpzip.okio.asSink
ZipOutputStream(sink).use { zos ->
zos.putNextEntry(ZipEntry("data.json"))
val entrySink = zos.asSink().buffer()
// Stream directly into the ZIP entry — e.g. Json.encodeToSink(serializer, value, entrySink)
entrySink.writeUtf8("""{"hello": "world"}""")
entrySink.flush()
zos.closeEntry()
}kotlinx-io:
import kotlinx.io.buffered
import kotlinx.io.writeString
import no.synth.kmpzip.kotlinx.ZipOutputStream
import no.synth.kmpzip.kotlinx.asSink
ZipOutputStream(sink).use { zos ->
zos.putNextEntry(ZipEntry("data.json"))
val entrySink = zos.asSink().buffered()
// Stream directly into the ZIP entry — e.g. Json.encodeToSink(serializer, value, entrySink)
entrySink.writeString("""{"hello": "world"}""")
entrySink.flush()
zos.closeEntry()
}Both kmp-zip-okio and kmp-zip-kotlinx ship suspend extensions on their respective FileSystem types that zip files and directories straight from disk and extract archives back to disk. The helpers run their I/O on a configurable CoroutineContext (defaulting to Dispatchers.IO on JVM and Dispatchers.Default on native) and are cooperatively cancellable at every read/write chunk and entry boundary.
OkIO:
import okio.FileSystem
import okio.Path.Companion.toPath
import no.synth.kmpzip.okio.unzipFrom
import no.synth.kmpzip.okio.zipTo
suspend fun example() {
val fs = FileSystem.SYSTEM
fs.zipTo(
target = "out/archive.zip".toPath(),
sources = listOf("src/main".toPath(), "README.md".toPath()),
password = "swordfish", // optional — AES-256 by default
)
fs.unzipFrom(
archive = "out/archive.zip".toPath(),
target = "extracted".toPath(),
password = "swordfish",
)
}kotlinx-io:
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import no.synth.kmpzip.kotlinx.unzipFrom
import no.synth.kmpzip.kotlinx.zipTo
suspend fun example() {
SystemFileSystem.zipTo(
target = Path("out/archive.zip"),
sources = listOf(Path("src/main"), Path("README.md")),
)
SystemFileSystem.unzipFrom(
archive = Path("out/archive.zip"),
target = Path("extracted"),
)
}Both helpers walk directories recursively, follow symlinks, do not preserve Unix mode bits, and reject extracted entry names that would escape target (absolute paths, drive letters, parent traversal, control chars).
The helpers are not provided for the js and wasmJs targets — a browser has no filesystem, and Node's filesystem is not exposed by these adapter modules. Use the streaming ByteArray API there.
The kmp-zip-cli module provides a command-line tool for ZIP and GZIP operations, powered by the core library.
kmpzip is available through Homebrew on macOS and Linux:
brew install henrik242/brew/kmpzipPrebuilt binaries for macOS, Linux, and Windows are also attached to each GitHub release.
Alternatively, build a native binary from source:
./gradlew :kmp-zip-cli:linkReleaseExecutableMacosArm64 # or MacosX64, LinuxX64, LinuxArm64, MingwX64
kmp-zip-cli/build/bin/macosArm64/releaseExecutable/kmpzip-macos-arm64 <command> [options] [args]Hosts without a native target can run the CLI on the JVM: ./gradlew :kmp-zip-cli:jvmRun --args="list archive.zip".
kmpzip <command> [options] [args]Password encoding: the -p argument is encoded as UTF-8. ASCII passwords interoperate with all common ZIP tools. Non-ASCII passwords work between kmp-zip's own implementations (JVM, native, library API), but may not match unzip / Info-ZIP, which use the system locale / CP437.
Linux binary portability: the linuxX64 / linuxArm64 builds dynamically link the host's glibc. They run on common distros (Debian, Ubuntu, RHEL/Alma/Rocky) and on gcr.io/distroless/cc-debian12, but not on Alpine / musl or gcr.io/distroless/static. zlib is statically linked, so there's no libz.so.1 runtime dependency.
| Command | Alias | Description |
|---|---|---|
list <file.zip> |
l |
List ZIP contents (method, size, compressed size, name) |
unzip <file.zip> [-d dir] [-p password] |
x |
Extract ZIP contents to a directory |
zip <file.zip> [-p password] [--legacy] <files..> |
c |
Create ZIP from files and directories (recursive) |
gzip <file> |
z |
GZIP compress a file (creates <file>.gz) |
gunzip <file.gz> |
u |
GZIP decompress a file |
help |
-h, --help |
Show usage information |
# List contents of a ZIP file
kmpzip list archive.zip
# Extract to a specific directory
kmpzip unzip archive.zip -d output/
# Create a ZIP from files and directories
kmpzip zip archive.zip file.txt src/
# Create an AES-encrypted ZIP (default)
kmpzip zip secret.zip -p mypassword file.txt
# Create a legacy ZipCrypto-encrypted ZIP (compatible with macOS Finder, Windows Explorer)
kmpzip zip compat.zip -p mypassword --legacy file.txt
# Extract an encrypted ZIP (auto-detects AES or legacy)
kmpzip unzip secret.zip -p mypassword -d output/
# GZIP compress / decompress
kmpzip gzip largefile.txt
kmpzip gunzip largefile.txt.gzRequires JDK 21 and Xcode (for iOS targets). The Java 8 baseline comes from compiler flags rather than a toolchain pin, so no JDK 8 install is needed.
./gradlew build # Full build
./gradlew jvmTest # JVM tests
./gradlew iosSimulatorArm64Test # iOS simulator testsTo run the JVM tests on a real JDK 8 (it must already be installed):
./gradlew jvmTest -Ptest.jvm=8
./gradlew publishToMavenLocal && scripts/check-java8-artifacts.sh
./gradlew -p scripts/java8-consumer run -PkmpzipVersion=0.15.0Tagging a release triggers the GitHub Actions workflow to publish to Maven Central:
git tag v0.15.0
git push origin v0.15.0