Skip to content

henrik242/kmp-zip

Repository files navigation

kmp-zip

Kotlin Multiplatform ZIP and GZIP library for JVM, iOS, macOS, Linux, Windows, 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 zip and 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 wasmJs.

Modules

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.

Targets

  • JVM (also consumable from Android projects)
  • iosArm64, iosSimulatorArm64
  • macosArm64, macosX64
  • linuxX64, linuxArm64
  • mingwX64
  • wasmJs (browser, Node 20+) — see wasmJs notes below

wasmJs notes

The wasmJs target ships the same library API as every other target. There is no kmp-zip-cli for wasmJs and no filesystem helpers — work with ByteArray and the stream classes, and wire any file I/O on the host side.

A working browser sample lives in samples/wasmjs-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. Run it with:

./gradlew :samples:wasmjs-demo:wasmJsBrowserDevelopmentRun

That builds the wasm bundle, starts a webpack dev server on http://localhost:8080, and opens it. The sample also ships a wasmJsNodeTest harness that runs every archive in kmp-zip/src/commonTest/resources/testdata/ through the sample's pipeline — see samples/wasmjs-demo/README.md.

Runtime caveats:

  • pako runtime dependency. Deflate/inflate is delegated to pako 2.1.0, pinned exactly. Kotlin's wasmJs build picks it up automatically — the kmp-zip project's kotlin-js-store/wasm/yarn.lock records the tarball SHA-512 (sha512-w+eufiZ1...). Downstream consumers manage their own lockfile; commit yours and keep --frozen-lockfile on in CI. pako adds ~45 KB minified (~14 KB gzipped) to a wasmJs bundle and is not effectively tree-shakeable.
  • Crypto.randomBytes requires Web Crypto. Calls globalThis.crypto.getRandomValues, which is available in any browser context (HTTPS or plain http://) and Node 20+. If the runtime doesn't expose Web Crypto — sandboxed JS realms, browsers with Web Crypto disabled by policy — the call throws IllegalStateException naming 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. Kotlin/Wasm has no window/document dependency, so a worker works without extra setup.

Threat model

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, 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. wasmJs is the most exposed target — browser-side ZIP reading of untrusted input is a liability and should be validated accordingly.

Installation

Published on Maven Central. No special repository configuration needed.

kotlin {
    // Add the targets you need, including wasmJs:
    // wasmJs { browser(); nodejs() }

    sourceSets {
        commonMain {
            dependencies {
                implementation("no.synth:kmp-zip:0.11.2")

                // Optional: kotlinx-io adapters
                implementation("no.synth:kmp-zip-kotlinx:0.11.2")

                // Optional: OkIO adapters
                implementation("no.synth:kmp-zip-okio:0.11.2")
            }
        }
    }
}

API

kmp-zipno.synth.kmpzip.io

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

kmp-zipno.synth.kmpzip.zip

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
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
ZipConstants STORED = 0, DEFLATED = 8
ZipEncryption AES (default, strong), LEGACY (PKWare traditional, for compatibility)
AesStrength AES_128, AES_192, AES_256 (default)

kmp-zipno.synth.kmpzip.crypto

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

kmp-zipno.synth.kmpzip.gzip

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()

kmp-zip-kotlinxno.synth.kmpzip.kotlinx

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

kmp-zip-okiono.synth.kmpzip.okio

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
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

Usage

Read a ZIP from a ByteArray

ZipInputStream(zipBytes).use { zis ->
    while (true) {
        val entry = zis.nextEntry ?: break
        println("${entry.name}: ${zis.readBytes().decodeToString()}")
    }
}

Create a ZIP into a ByteArray

val buf = ByteArrayOutputStream()
ZipOutputStream(buf).use { zos ->
    zos.putNextEntry(ZipEntry("hello.txt"))
    zos.write("Hello, world!".encodeToByteArray())
    zos.closeEntry()
}
val zipBytes = buf.toByteArray()

Read an AES-encrypted ZIP

ZipInputStream(zipBytes, password = "secret").use { zis ->
    while (true) {
        val entry = zis.nextEntry ?: break
        println("${entry.name}: ${zis.readBytes().decodeToString()}")
    }
}

Create an AES-encrypted ZIP

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)

Create a legacy-encrypted ZIP (ZipCrypto)

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.

Standalone crypto primitives

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)

GZIP compress and decompress

// 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()
}

kotlinx-io: read/write ZIPs via Source/Sink

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()}")
}

OkIO: read/write ZIPs via BufferedSource/BufferedSink

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()}")
}

Stream data from a ZIP entry via Source

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()}")
    }
}

Stream data directly into a ZIP entry via Sink

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()
}

Suspend filesystem helpers

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 wasmJs target — browser wasm has no filesystem, and Node's wasm filesystem support is not exposed by these adapter modules. Use the streaming ByteArray API there.

CLI

The kmp-zip-cli module provides a command-line tool for ZIP and GZIP operations, powered by the core library.

Running

./kmpzip <command> [options] [args]

The ./kmpzip wrapper picks a native binary for the host (macosArm64, linuxX64, linuxArm64) and execs it directly — no JVM startup. The first invocation lazily builds the binary via Gradle; subsequent runs are instant. Hosts without a native binary fall back to ./gradlew :kmp-zip-cli:jvmRun. On Windows, use kmpzip.cmd instead, which builds and execs the mingwX64 .exe.

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.

Commands

Command Alias Description
list <file.zip> [-p password] 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

Examples

# 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.gz

Installing

kmpzip is available through Homebrew on macOS and Linux:

brew install henrik242/brew/kmpzip

Building

Requires JDK 21 and Xcode (for iOS targets).

./gradlew build                      # Full build
./gradlew jvmTest                    # JVM tests
./gradlew iosSimulatorArm64Test      # iOS simulator tests

Publishing

Tagging a release triggers the GitHub Actions workflow to publish to Maven Central:

git tag v0.11.2
git push origin v0.11.2

License

Mozilla Public License 2.0 (MPL-2.0)

About

Kotlin Multiplatform ZIP stream library for JVM and iOS targets.

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Packages

 
 
 

Contributors

Languages