Backup adalah jaring pengaman terakhir sebuah sistem. Masalahnya, backup juga merupakan salinan lengkap dari data paling sensitif yang Anda miliki, dan salinan itu biasanya disimpan di infrastruktur pihak lain.

Pendekatan zero-knowledge menyelesaikan masalah tersebut dengan cara sederhana: data dienkripsi di sisi klien sebelum meninggalkan mesin pengguna, dan kunci enkripsi tidak pernah dikirim ke server. Penyedia penyimpanan hanya memegang blob biner yang tidak bisa dibaca.

Artikel ini membahas cara kerja arsitektur tersebut dan mengimplementasikannya menjadi CLI backup yang berfungsi, dengan AES-256-GCM dan key derivation berbasis passphrase.

Tujuan Tutorial

Setelah menyelesaikan tutorial ini, Anda akan memiliki:

  • Pemahaman tentang model ancaman yang dijawab oleh arsitektur zero-knowledge.

  • CLI Python untuk membuat arsip terenkripsi dari file atau direktori.

  • Implementasi enkripsi berbasis chunk yang aman untuk file besar tanpa memuat seluruh file ke memori.

  • Proteksi terhadap manipulasi arsip, termasuk serangan pemotongan dan penukaran urutan chunk.

  • Unit test untuk memverifikasi roundtrip enkripsi dan deteksi manipulasi.

Prasyarat

  • Python 3.12 atau lebih baru.

  • Pemahaman dasar tentang terminal Linux dan virtual environment Python.

  • Pemahaman dasar tentang konsep kunci simetris dan hashing.

Tutorial ini ditujukan untuk pembaca tingkat menengah. Anda tidak perlu latar belakang kriptografi formal, tetapi istilah seperti nonce dan salt tidak akan dijelaskan dari nol.

Apa Arti Zero-Knowledge dalam Konteks Backup

Istilah zero-knowledge di industri backup tidak merujuk pada zero-knowledge proof dalam kriptografi akademis. Maknanya lebih praktis: penyedia layanan tidak memiliki pengetahuan apa pun tentang isi data pelanggan.

Konsekuensi teknisnya cukup ketat:

  • Enkripsi dan dekripsi dilakukan sepenuhnya di sisi klien.

  • Passphrase atau kunci tidak pernah dikirim ke server, termasuk dalam bentuk hash yang bisa dipakai untuk dekripsi.

  • Server hanya menerima ciphertext, dan metadata yang disimpan dijaga seminimal mungkin.

  • Jika pengguna kehilangan passphrase, data tidak dapat dipulihkan oleh siapa pun, termasuk oleh penyedia layanan.

Poin terakhir bukan kelemahan implementasi, melainkan konsekuensi langsung dari desainnya. Fitur reset password yang bisa mengembalikan akses ke data lama secara otomatis membatalkan klaim zero-knowledge.

Model Ancaman yang Ditangani

Arsitektur ini melindungi data ketika:

  • Server penyimpanan disusupi atau hard disk-nya dicuri.

  • Administrator penyedia layanan berlaku tidak jujur.

  • Backup ikut bocor dalam sebuah data breach.

  • Jalur transportasi disadap, sebagai lapisan pertahanan di atas TLS.

Model Ancaman yang Tidak Ditangani

Ada batas yang harus dinyatakan dengan jujur kepada pengguna:

  • Mesin klien yang sudah terinfeksi malware. Passphrase diketik di sana.

  • Passphrase lemah yang bisa ditebak dengan serangan kamus.

  • Kebocoran metadata seperti ukuran arsip, waktu backup, dan frekuensi backup.

  • Kehilangan passphrase oleh pengguna.

Desain Format Arsip

Sebelum menulis kode, format file perlu ditetapkan lebih dulu. Format yang buruk hampir selalu berakhir menjadi celah keamanan atau masalah kompatibilitas di kemudian hari.

Arsip yang dibangun di sini terdiri atas header plaintext dan sederet chunk terenkripsi.

+--------------------------------------------------+
| HEADER (29 byte, plaintext)                       |
|   magic         : 4 byte  = "ZKB1"                |
|   version       : 1 byte                          |
|   salt          : 16 byte (untuk KDF)             |
|   nonce_prefix  : 4 byte                          |
|   chunk_size    : 4 byte (unsigned int)           |
+--------------------------------------------------+
| CHUNK 0 : panjang (4 byte) + ciphertext + tag     |
| CHUNK 1 : panjang (4 byte) + ciphertext + tag     |
| ...                                               |
+--------------------------------------------------+

Beberapa keputusan desain yang penting:

Header sengaja disimpan sebagai plaintext karena salt dan nonce_prefix memang tidak rahasia. Keduanya wajib diketahui sebelum kunci bisa diturunkan.

Setiap chunk dienkripsi dengan nonce unik yang dibentuk dari nonce_prefix ditambah counter 64-bit. Penggunaan ulang nonce pada AES-GCM adalah kesalahan fatal, sehingga counter tidak boleh diacak.

Seluruh header dimasukkan sebagai Additional Authenticated Data (AAD) pada setiap chunk. Dengan begitu, penyerang tidak bisa mengganti salt atau chunk_size tanpa membuat verifikasi tag gagal.

AAD juga memuat indeks chunk dan flag penanda chunk terakhir. Indeks mencegah penukaran urutan chunk, sedangkan flag mencegah serangan pemotongan file di mana penyerang menghapus chunk terakhir agar restore menghasilkan data yang tidak lengkap namun tetap valid.

Struktur Project

zk-backup/
├── requirements.txt
├── zkbackup/
│   ├── __init__.py
│   ├── crypto.py
│   ├── archive.py
│   └── cli.py
└── tests/
    └── test_crypto.py

Instalasi Dependency

Terminal

mkdir -p zk-backup/zkbackup zk-backup/tests
cd zk-backup
python3 -m venv .venv
source .venv/bin/activate

Perintah di atas menyiapkan direktori project dan virtual environment. Semua perintah berikutnya diasumsikan dijalankan dari direktori zk-backup dengan virtual environment aktif.

requirements.txt

cryptography>=42.0.0
pytest>=8.0.0

Library cryptography menyediakan implementasi AES-GCM dan Scrypt yang didukung backend OpenSSL. Hindari menulis primitif kriptografi sendiri. Package pytest hanya dibutuhkan untuk menjalankan unit test.

Terminal

pip install --requirement requirements.txt

Implementasi Modul Kriptografi

Modul ini adalah inti dari seluruh sistem. Semua operasi enkripsi dan dekripsi terjadi di sini.

zkbackup/crypto.py

import struct
import os

from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt

MAGIC = b"ZKB1"
VERSION = 1

SALT_SIZE = 16
NONCE_PREFIX_SIZE = 4
KEY_SIZE = 32
CHUNK_SIZE = 1024 * 1024

SCRYPT_N = 2 ** 15
SCRYPT_R = 8
SCRYPT_P = 1

HEADER_FORMAT = "!4sB16s4sI"
HEADER_SIZE = struct.calcsize(HEADER_FORMAT)


class ArchiveError(Exception):
    """Kesalahan pada format arsip, passphrase, atau integritas data."""


def derive_key(passphrase: str, salt: bytes) -> bytes:
    kdf = Scrypt(
        salt=salt,
        length=KEY_SIZE,
        n=SCRYPT_N,
        r=SCRYPT_R,
        p=SCRYPT_P,
    )
    return kdf.derive(passphrase.encode("utf-8"))


def build_header(salt: bytes, nonce_prefix: bytes, chunk_size: int) -> bytes:
    return struct.pack(HEADER_FORMAT, MAGIC, VERSION, salt, nonce_prefix, chunk_size)


def parse_header(header: bytes) -> tuple[bytes, bytes, int]:
    if len(header) != HEADER_SIZE:
        raise ArchiveError("Header arsip tidak lengkap.")

    magic, version, salt, nonce_prefix, chunk_size = struct.unpack(HEADER_FORMAT, header)

    if magic != MAGIC:
        raise ArchiveError("File ini bukan arsip ZKB.")
    if version != VERSION:
        raise ArchiveError(f"Versi arsip tidak didukung: {version}")
    if chunk_size <= 0:
        raise ArchiveError("Ukuran chunk pada header tidak valid.")

    return salt, nonce_prefix, chunk_size


def build_nonce(nonce_prefix: bytes, counter: int) -> bytes:
    return nonce_prefix + struct.pack("!Q", counter)


def build_aad(header: bytes, counter: int, is_last: bool) -> bytes:
    return header + struct.pack("!Q?", counter, is_last)


def encrypt_stream(source, target, passphrase: str) -> None:
    salt = os.urandom(SALT_SIZE)
    nonce_prefix = os.urandom(NONCE_PREFIX_SIZE)
    key = derive_key(passphrase, salt)
    aead = AESGCM(key)

    header = build_header(salt, nonce_prefix, CHUNK_SIZE)
    target.write(header)

    counter = 0
    chunk = source.read(CHUNK_SIZE)

    while True:
        lookahead = source.read(CHUNK_SIZE)
        is_last = len(lookahead) == 0

        nonce = build_nonce(nonce_prefix, counter)
        aad = build_aad(header, counter, is_last)
        blob = aead.encrypt(nonce, chunk, aad)

        target.write(struct.pack("!I", len(blob)))
        target.write(blob)

        counter += 1
        if is_last:
            break
        chunk = lookahead


def decrypt_stream(source, target, passphrase: str) -> None:
    header = source.read(HEADER_SIZE)
    salt, nonce_prefix, chunk_size = parse_header(header)

    key = derive_key(passphrase, salt)
    aead = AESGCM(key)

    counter = 0
    pending = source.read(4)

    while True:
        if len(pending) != 4:
            raise ArchiveError("Arsip terpotong atau rusak.")

        blob_size = struct.unpack("!I", pending)[0]
        blob = source.read(blob_size)
        if len(blob) != blob_size:
            raise ArchiveError("Arsip terpotong atau rusak.")

        pending = source.read(4)
        is_last = len(pending) == 0

        nonce = build_nonce(nonce_prefix, counter)
        aad = build_aad(header, counter, is_last)

        try:
            plaintext = aead.decrypt(nonce, blob, aad)
        except InvalidTag as error:
            raise ArchiveError(
                "Dekripsi gagal. Passphrase salah atau arsip telah dimodifikasi."
            ) from error

        target.write(plaintext)

        counter += 1
        if is_last:
            break

Fungsi derive_key() mengubah passphrase menjadi kunci 256-bit menggunakan Scrypt. Parameter SCRYPT_N bernilai 32768 membuat proses derivasi sengaja lambat dan boros memori, sehingga serangan brute force menjadi jauh lebih mahal. Nilai ini kira-kira membutuhkan 32 MB RAM per percobaan.

Fungsi encrypt_stream() membaca sumber satu chunk pada satu waktu, sehingga konsumsi memori tetap stabil meskipun file berukuran puluhan gigabyte. Teknik lookahead dipakai untuk mengetahui apakah sebuah chunk merupakan chunk terakhir sebelum chunk tersebut dienkripsi, karena informasi itu ikut masuk ke dalam AAD.

Fungsi decrypt_stream() menggunakan pola serupa dengan membaca panjang chunk berikutnya lebih dulu. Jika pembacaan tersebut mengembalikan nol byte, chunk yang sedang diproses adalah chunk terakhir.

Perhatikan bahwa exception InvalidTag tidak diteruskan apa adanya, tetapi dibungkus menjadi ArchiveError dengan pesan yang netral. Passphrase salah dan arsip dimodifikasi sengaja tidak dibedakan agar tidak memberi informasi tambahan kepada penyerang.

Perhatikan juga bahwa nilai chunk_size dari header tidak dipakai untuk membaca data, karena panjang setiap chunk sudah disimpan secara eksplisit. Nilai tersebut disimpan untuk kebutuhan validasi dan kompatibilitas format di masa depan.

Implementasi Modul Arsip

Enkripsi bekerja pada aliran byte, sedangkan pengguna ingin membackup file dan direktori. Modul ini menjembatani keduanya menggunakan tarfile.

zkbackup/archive.py

import tarfile
from pathlib import Path


def create_archive(sources: list[str], archive_path: str) -> None:
    with tarfile.open(archive_path, "w:gz") as tar:
        for source in sources:
            path = Path(source)
            if not path.exists():
                raise FileNotFoundError(f"Path tidak ditemukan: {source}")
            tar.add(path, arcname=path.name)


def extract_archive(archive_path: str, destination: str) -> None:
    target = Path(destination)
    target.mkdir(parents=True, exist_ok=True)

    with tarfile.open(archive_path, "r:gz") as tar:
        tar.extractall(path=target, filter="data")

Fungsi create_archive() membungkus semua sumber menjadi satu arsip tar.gz. Penggunaan arcname=path.name mencegah path absolut ikut tersimpan di dalam arsip.

Parameter filter="data" pada extractall() merupakan bagian penting dari keamanan restore. Filter tersebut menolak entri berbahaya seperti path absolut, path yang keluar dari direktori tujuan, symlink, serta device file. Parameter ini tersedia sejak Python 3.12. Pada versi Python yang lebih lama, extractall() tanpa filter rentan terhadap serangan path traversal.

Kompresi dilakukan sebelum enkripsi karena ciphertext bersifat acak dan praktis tidak dapat dikompresi. Perlu dicatat bahwa urutan ini membuat ukuran akhir arsip membocorkan sedikit informasi tentang tingkat kompresibilitas data asli.

zkbackup/__init__.py

__version__ = "1.0.0"

File ini menjadikan direktori zkbackup sebagai package Python yang bisa diimpor.

Implementasi CLI

zkbackup/cli.py

import argparse
import getpass
import os
import sys
import tempfile

from zkbackup.archive import create_archive, extract_archive
from zkbackup.crypto import ArchiveError, decrypt_stream, encrypt_stream

MIN_PASSPHRASE_LENGTH = 12


def ask_passphrase(confirm: bool) -> str:
    passphrase = getpass.getpass("Passphrase: ")

    if len(passphrase) < MIN_PASSPHRASE_LENGTH:
        raise ValueError(
            f"Passphrase minimal {MIN_PASSPHRASE_LENGTH} karakter."
        )

    if confirm:
        repeated = getpass.getpass("Ulangi passphrase: ")
        if passphrase != repeated:
            raise ValueError("Passphrase tidak sama.")

    return passphrase


def run_backup(sources: list[str], output: str) -> None:
    passphrase = ask_passphrase(confirm=True)

    handle, staging_path = tempfile.mkstemp(suffix=".tar.gz")
    os.close(handle)

    try:
        create_archive(sources, staging_path)
        with open(staging_path, "rb") as source, open(output, "wb") as target:
            encrypt_stream(source, target, passphrase)
    finally:
        os.remove(staging_path)

    size = os.path.getsize(output)
    print(f"Backup selesai: {output} ({size} byte)")


def run_restore(archive: str, destination: str) -> None:
    passphrase = ask_passphrase(confirm=False)

    handle, staging_path = tempfile.mkstemp(suffix=".tar.gz")
    os.close(handle)

    try:
        with open(archive, "rb") as source, open(staging_path, "wb") as target:
            decrypt_stream(source, target, passphrase)
        extract_archive(staging_path, destination)
    finally:
        os.remove(staging_path)

    print(f"Restore selesai ke: {destination}")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="zkbackup",
        description="Backup terenkripsi zero-knowledge berbasis AES-256-GCM.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    backup = subparsers.add_parser("backup", help="Membuat arsip terenkripsi.")
    backup.add_argument("sources", nargs="+", help="File atau direktori sumber.")
    backup.add_argument("--output", "-o", required=True, help="Path arsip hasil.")

    restore = subparsers.add_parser("restore", help="Memulihkan arsip terenkripsi.")
    restore.add_argument("archive", help="Path arsip terenkripsi.")
    restore.add_argument("--dest", "-d", default=".", help="Direktori tujuan.")

    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    try:
        if args.command == "backup":
            run_backup(args.sources, args.output)
        else:
            run_restore(args.archive, args.dest)
    except (ArchiveError, ValueError, FileNotFoundError) as error:
        print(f"Error: {error}", file=sys.stderr)
        return 1

    return 0


if __name__ == "__main__":
    sys.exit(main())

CLI ini menyediakan dua subcommand, yaitu backup dan restore. Passphrase dibaca melalui getpass.getpass() agar tidak tampil di layar dan tidak tercatat di shell history. Hindari mengirim passphrase sebagai argumen command line, karena argumen dapat terlihat oleh pengguna lain melalui ps.

File tar.gz sementara dibuat menggunakan tempfile.mkstemp() yang secara default memberikan permission 0600, lalu dihapus di blok finally agar tetap terhapus meskipun terjadi error. Perlu diketahui bahwa file sementara ini berisi data plaintext, sehingga direktori temporary sebaiknya berada pada disk yang terenkripsi.

Cara Menjalankan

Command

cd zk-backup
source .venv/bin/activate
python3 -m zkbackup.cli backup /var/www/app --output backup-app.zkb

Output yang diharapkan

Passphrase: 
Ulangi passphrase: 
Backup selesai: backup-app.zkb (10485778 byte)

Nilai ukuran file pada output tentu bergantung pada data yang Anda backup.

Untuk memulihkan arsip:

Command

python3 -m zkbackup.cli restore backup-app.zkb --dest ./hasil-restore

Output yang diharapkan

Passphrase: 
Restore selesai ke: ./hasil-restore

Jika passphrase yang dimasukkan salah, proses berhenti pada chunk pertama:

Output yang diharapkan

Passphrase: 
Error: Dekripsi gagal. Passphrase salah atau arsip telah dimodifikasi.

Pengujian

Unit test berikut memverifikasi tiga properti utama, yaitu roundtrip yang konsisten, penolakan passphrase salah, dan deteksi manipulasi ciphertext.

tests/test_crypto.py

import io

import pytest

from zkbackup.crypto import ArchiveError, HEADER_SIZE, decrypt_stream, encrypt_stream

PASSPHRASE = "passphrase-uji-yang-panjang"


def roundtrip(payload: bytes) -> bytes:
    encrypted = io.BytesIO()
    encrypt_stream(io.BytesIO(payload), encrypted, PASSPHRASE)

    decrypted = io.BytesIO()
    encrypted.seek(0)
    decrypt_stream(encrypted, decrypted, PASSPHRASE)

    return decrypted.getvalue()


def test_roundtrip_data_kecil():
    payload = b"data rahasia perusahaan"
    assert roundtrip(payload) == payload


def test_roundtrip_file_kosong():
    assert roundtrip(b"") == b""


def test_roundtrip_multi_chunk():
    payload = b"A" * (1024 * 1024 * 2 + 512)
    assert roundtrip(payload) == payload


def test_passphrase_salah_ditolak():
    encrypted = io.BytesIO()
    encrypt_stream(io.BytesIO(b"data rahasia"), encrypted, PASSPHRASE)
    encrypted.seek(0)

    with pytest.raises(ArchiveError):
        decrypt_stream(encrypted, io.BytesIO(), "passphrase-yang-berbeda")


def test_ciphertext_dimodifikasi_terdeteksi():
    encrypted = io.BytesIO()
    encrypt_stream(io.BytesIO(b"data rahasia"), encrypted, PASSPHRASE)

    blob = bytearray(encrypted.getvalue())
    index = HEADER_SIZE + 8
    blob[index] ^= 0x01

    with pytest.raises(ArchiveError):
        decrypt_stream(io.BytesIO(bytes(blob)), io.BytesIO(), PASSPHRASE)


def test_arsip_terpotong_terdeteksi():
    encrypted = io.BytesIO()
    encrypt_stream(io.BytesIO(b"B" * (1024 * 1024 + 10)), encrypted, PASSPHRASE)

    blob = encrypted.getvalue()
    truncated = blob[: len(blob) // 2]

    with pytest.raises(ArchiveError):
        decrypt_stream(io.BytesIO(truncated), io.BytesIO(), PASSPHRASE)

Jalankan pengujian dari direktori root project agar package zkbackup dapat diimpor.

Command

python3 -m pytest tests/ -v

Test test_roundtrip_multi_chunk sengaja menggunakan payload yang melewati batas satu chunk dengan sisa tidak rata, karena bug pada logika chunking biasanya muncul tepat di kondisi tersebut.

Test test_arsip_terpotong_terdeteksi memverifikasi nilai praktis dari flag chunk terakhir di dalam AAD. Tanpa flag tersebut, arsip yang dipotong akan tetap terdekripsi tanpa error dan menghasilkan restore yang diam-diam tidak lengkap.

Perlu dicatat bahwa hasil eksekusi pengujian pada lingkungan Anda belum diverifikasi dalam artikel ini. [PERLU VERIFIKASI]

Troubleshooting

ModuleNotFoundError: No module named 'zkbackup'

Perintah dijalankan dari direktori yang salah. Jalankan python3 -m zkbackup.cli dari direktori root project, bukan dari dalam direktori zkbackup.

TypeError pada parameter filter di extractall

Parameter filter membutuhkan Python 3.12 atau lebih baru. Periksa versi dengan python3 --version. Menghapus parameter tersebut memang menghilangkan error, tetapi membuka celah path traversal saat restore, sehingga sebaiknya lakukan upgrade Python.

Proses backup terasa lambat saat mulai berjalan

Jeda beberapa ratus milidetik hingga beberapa detik di awal berasal dari Scrypt dan memang disengaja. Jika target Anda adalah perangkat dengan memori sangat terbatas, turunkan SCRYPT_N. Perlu diingat bahwa arsip lama tidak akan bisa didekripsi setelah parameter diubah, kecuali parameter KDF ikut disimpan di dalam header.

MemoryError saat menjalankan backup

Nilai CHUNK_SIZE terlalu besar untuk lingkungan Anda, atau Scrypt kekurangan memori. Konsumsi memori proses kira-kira sebesar CHUNK_SIZE ditambah 32 MB untuk Scrypt.

Arsip dapat didekripsi, tetapi tar gagal diekstrak

Ini menandakan masalah pada lapisan tar.gz, bukan pada lapisan enkripsi, karena verifikasi tag GCM sudah lolos. Umumnya penyebabnya adalah proses backup yang terputus sebelum selesai menulis arsip.

Catatan Produksi

Implementasi di atas sudah aman secara kriptografis untuk penggunaan pribadi, tetapi layanan backup komersial masih membutuhkan beberapa lapisan tambahan:

Simpan parameter KDF di dalam header agar parameter dapat dinaikkan seiring waktu tanpa merusak kompatibilitas arsip lama.

Pertimbangkan skema dua tingkat, yaitu kunci data acak yang dienkripsi oleh kunci turunan passphrase. Dengan skema ini, penggantian passphrase cukup mengenkripsi ulang kunci data, bukan seluruh arsip.

Sediakan recovery key yang dibuat dan disimpan sendiri oleh pengguna. Ini adalah satu-satunya bentuk pemulihan yang tidak melanggar prinsip zero-knowledge.

Lakukan uji restore secara berkala dan terjadwal. Backup yang belum pernah diuji restore secara utuh sebaiknya diperlakukan sebagai backup yang belum tentu ada.

Kesimpulan

Arsitektur zero-knowledge memindahkan batas kepercayaan dari penyedia layanan ke pengguna. Implementasinya sendiri tidak rumit karena hanya terdiri atas tiga elemen: key derivation yang lambat, authenticated encryption per chunk, dan format arsip yang mengikat metadata ke dalam AAD.

Bagian yang jauh lebih menantang justru berada di luar kriptografi, yaitu manajemen kunci, prosedur pemulihan, dan komunikasi yang jujur bahwa passphrase yang hilang berarti data yang hilang.

Langkah lanjutan yang layak dikerjakan adalah backup inkremental berbasis konten, verifikasi integritas arsip tanpa perlu melakukan restore penuh, serta uji disaster recovery menyeluruh dari file terenkripsi hingga aplikasi kembali berjalan.