Pertukaran data antar pihak—file kontrak, pesan JSON, atau artefak build—memerlukan dua jaminan: integritas (data tidak dimodifikasi) dan keaslian (data memang berasal dari pihak yang diklaim). Hash function seperti SHA-256 menyediakan jaminan integritas: setiap perubahan kecil pada input menghasilkan hash yang sepenuhnya berbeda. Tanda tangan digital menambahkan autentikasi: penandatangan memakai kunci privat untuk menandatangani hash, dan siapa pun dapat memverifikasi dengan kunci publik. Pada chapter ini kita akan mempraktikkan SHA-256 digest, tanda tangan RSA, serta tanda tangan ECDSA dengan API bawaan JDK.

Message Digest: Verifikasi Integritas #

SHA-256 mengubah input apa pun menjadi 32 byte (256 bit) yang merepresentasikan sidik jari unik. Dua file dengan digest identik diyakini memiliki konten yang sama. Digest cocok untuk verifikasi unduhan: server mempublikasikan digest resmi, dan klien menghitung digest file hasil unduhan untuk membandingkan.

kotlin SHA-256 digest sederhana
import java.security.MessageDigest

fun digest(data: ByteArray): ByteArray =
    MessageDigest.getInstance("SHA-256").digest(data)

fun hex(bytes: ByteArray): String =
    bytes.joinToString("") { "%02x".format(it) }

Satu perubahan kecil pada input—misalnya mengganti 100 menjadi 200 pada teks kontrak—mengubah seluruh 32 byte digest. Inilah avalanche effect yang membuat SHA-256 sulit dipalsukan.

Tanda Tangan Digital: Autentikasi #

Hash sendiri tidak membuktikan pengirim. Penyerang dapat mengganti file, menghitung hash baru, dan mengirimkan keduanya. Tanda tangan digital menyelesaikan masalah ini dengan kunci asimetris: pengirim menandatangani hash dengan kunci privat, penerima memverifikasi dengan kunci publik pengirim. Hanya pemegang kunci privat yang dapat membuat tanda tangan valid.

kotlin
// message_digests_dan_signatures.kt
// SHA-256 digest. RSA/ECDSA signature. Verify integrity and authenticity.
// Demonstrates: digest comparison, signature create/verify, tamper detection.

import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.Signature

// Stable SecureRandom for deterministic demo output
private val DEMO_RNG: SecureRandom = SecureRandom.getInstance("SHA1PRNG").apply {
    setSeed("kotlin-course-stable-seed".toByteArray())
}

// --- SHA-256 digest of arbitrary bytes ---
fun digest(data: ByteArray): ByteArray =
    MessageDigest.getInstance("SHA-256").digest(data)

fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) }

// --- RSA keypair generation (2048-bit) for signing ---
fun generateRsaKeyPair() = KeyPairGenerator.getInstance("RSA").run {
    initialize(2048, DEMO_RNG)
    generateKeyPair()
}

// --- Sign bytes with RSA private key (SHA-256 with RSA, PKCS#1 v1.5) ---
fun signRsa(data: ByteArray): Pair<ByteArray, java.security.KeyPair> {
    val keyPair = generateRsaKeyPair()
    val sig = Signature.getInstance("SHA256withRSA")
    sig.initSign(keyPair.private)
    sig.update(data)
    return sig.sign() to keyPair
}

// --- Verify signature with RSA public key ---
fun verifyRsa(data: ByteArray, signature: ByteArray, publicKey: java.security.PublicKey): Boolean {
    val sig = Signature.getInstance("SHA256withRSA")
    sig.initVerify(publicKey)
    sig.update(data)
    return sig.verify(signature)
}

// --- ECDSA keypair generation (P-256) ---
fun generateEcdsaKeyPair() = KeyPairGenerator.getInstance("EC").run {
    initialize(java.security.spec.ECGenParameterSpec("secp256r1"), DEMO_RNG)
    generateKeyPair()
}

// --- Sign with ECDSA (SHA-256) ---
fun signEcdsa(data: ByteArray): Pair<ByteArray, java.security.KeyPair> {
    val keyPair = generateEcdsaKeyPair()
    val sig = Signature.getInstance("SHA256withECDSA")
    sig.initSign(keyPair.private)
    sig.update(data)
    return sig.sign() to keyPair
}

// --- Verify with ECDSA ---
fun verifyEcdsa(data: ByteArray, signature: ByteArray, publicKey: java.security.PublicKey): Boolean {
    val sig = Signature.getInstance("SHA256withECDSA")
    sig.initVerify(publicKey)
    sig.update(data)
    return sig.verify(signature)
}

fun main() {
    // 1. SHA-256 digest: same input -> same digest; tampering changes digest
    val dokumenAsli = "Kontrak: Budi akan mengirim 100 unit ke Siti pada 15 Januari 2026.".toByteArray()
    val dokumenDiubah = "Kontrak: Budi akan mengirim 200 unit ke Siti pada 15 Januari 2026.".toByteArray()

    println("digest(asli)   : ${hex(digest(dokumenAsli)).take(32)}...")
    println("digest(diubah) : ${hex(digest(dokumenDiubah)).take(32)}...")
    println("Cocok? ${digest(dokumenAsli).contentEquals(digest(dokumenDiubah))}")
    println()

    // 2. RSA signature: create + verify + tamper detection
    val (sigRsa, keyPairRsa) = signRsa(dokumenAsli)
    println("RSA signature OK (${sigRsa.size} bytes, fixed-length untuk RSA-2048)")
    println("verify(asli, sigRsa)         = ${verifyRsa(dokumenAsli, sigRsa, keyPairRsa.public)}")
    println("verify(dokumenDiubah, sigRsa) = ${verifyRsa(dokumenDiubah, sigRsa, keyPairRsa.public)}")
    println()

    // 3. ECDSA signature: create + verify + tamper detection
    // Note: ECDSA signature in DER form has variable length (typically 70-72 bytes).
    // We do not print length to keep demo output deterministic.
    val (sigEcdsa, keyPairEcdsa) = signEcdsa(dokumenAsli)
    println("ECDSA signature OK (DER-encoded, panjang bervariasi per tanda tangan)")
    println("verify(asli, sigEcdsa)         = ${verifyEcdsa(dokumenAsli, sigEcdsa, keyPairEcdsa.public)}")
    println("verify(dokumenDiubah, sigEcdsa) = ${verifyEcdsa(dokumenDiubah, sigEcdsa, keyPairEcdsa.public)}")
}

Output yang Dihasilkan:

text
digest(asli)   : add0a4e0f8200b6c177cf29a7705ddea...
digest(diubah) : 3931d2508298c7e745d626516a59e33f...
Cocok? false

RSA signature OK (256 bytes, fixed-length untuk RSA-2048)
verify(asli, sigRsa)         = true
verify(dokumenDiubah, sigRsa) = false

ECDSA signature OK (DER-encoded, panjang bervariasi per tanda tangan)
verify(asli, sigEcdsa)         = true
verify(dokumenDiubah, sigEcdsa) = false

Mari kita bedah bagian kunci dari kode di atas:

  1. DEMO_RNG: instance SecureRandom dengan SHA1PRNG dan seed stabil untuk demo. Produksi harus memakai SecureRandom() tanpa argumen—keypair yang dihasilkan tidak boleh dapat diprediksi.
  2. digest: memanggil MessageDigest.getInstance("SHA-256") lalu digest(data). Hasilnya 32 byte. Fungsi hex menyandikan setiap byte menjadi dua digit heksadesimal.
  3. signRsa: membangkitkan keypair RSA 2048-bit, lalu menandatangani data dengan algoritma SHA256withRSA. Proses: hitung SHA-256 dari data, enkripsi hash dengan kunci privat. Output tanda tangan adalah 256 byte untuk RSA-2048.
  4. verifyRsa: mengulangi proses—hitung SHA-256 dari data yang diterima, dekripsi tanda tangan dengan kunci publik, bandingkan kedua hash. Bila data dimodifikasi, hash input tidak cocok dengan hash yang didekripsi dari tanda tangan, sehingga verify mengembalikan false.
  5. signEcdsa/verifyEcdsa: memakai kurva eliptik P-256 (secp256r1). ECDSA menghasilkan tanda tangan yang jauh lebih pendek (sekitar 64 byte) dibanding RSA-2048 dengan keamanan setara—ini menghemat bandwidth untuk komunikasi.

RSA vs ECDSA #

Pilihan algoritma memengaruhi ukuran kunci, ukuran tanda tangan, dan kecepatan. RSA mudah diimplementasikan dan memiliki dukungan luas, namun ukuran kunci besar membuat operasi lambat. ECDSA menawarkan keamanan setara dengan kunci yang jauh lebih kecil.

AlgoritmaUkuran kunciUkuran tanda tanganKecepatan verifikasi
RSA-20482048 bit256 byteCepat
ECDSA P-256256 bit64 byteSedang
Ed25519256 bit64 byteSangat cepat

Tabel di atas menunjukkan bahwa ECDSA dan Ed25519 menghasilkan tanda tangan yang jauh lebih ringkas. Untuk sistem baru, Ed25519 adalah pilihan modern yang lebih disarankan—sayangnya JDK standar belum menyediakannya tanpa library tambahan seperti BouncyCastle.

Deteksi Modifikasi dengan Digest #

Untuk verifikasi integritas tanpa autentikasi (misalnya membandingkan dua file lokal), digest saja sudah cukup. Bandingkan byte-per-byte hasil digest dengan contentEquals:

kotlin Perbandingan digest
val digestAsli = digest(dokumenAsli)
val digestDiubah = digest(dokumenDiubah)
val cocok = digestAsli.contentEquals(digestDiubah)
// cocok = false karena dokumenDiubah berbeda dari dokumenAsli

Perbandingan byte-per-byte lebih efisien daripada perbandingan string hex. Gunakan contentEquals untuk ByteArray agar membandingkan isi, bukan referensi.

Pola Penandatanganan dan Verifikasi #

Pola umum penandatanganan: pengirim menghitung digest dari data, menandatangani digest dengan kunci privat, lalu mengirim (data, tanda tangan) ke penerima. Penerima menghitung digest dari data yang diterima, memverifikasi tanda tangan dengan kunci publik pengirim, dan menerima data hanya bila verifikasi berhasil.

text
Pengirim:                                Penerima:
  data  ──┐                                data  ──┐
         │                                       │
  digest ←── SHA-256                       digest ←── SHA-256
         │                                       │
  sig   ←── sign(priv, digest)                   │
         │                                       │
  kirim (data, sig) ──────────────────→ verify(pub, sig, digest)
                                                 │
                                          valid? → terima/tolak

Pola ini memisahkan kebutuhan komputasi: hash cepat untuk data besar, sementara tanda tangan hanya memproses 32 byte hash. Inilah yang membuat kombinasi SHA-256 + RSA/ECDSA tetap praktis untuk file berukuran gigabyte.

Referensi Lanjutan #

Penutup #

Message digest dan tanda tangan digital adalah dua lapisan pertahanan untuk integritas dan autentikasi data. SHA-256 sendiri hanya menjawab pertanyaan “apakah data berubah”; tanda tangan digital menambahkan jawaban atas “siapa yang membuat data ini”. Chapter berikutnya akan membahas TLS dan validasi sertifikat—bagaimana kedua konsep ini dipakai dalam protokol transport aman yang dipakai HTTPS.