Materi sebelumnya menyimpan data apa adanya: teks sebagai teks, angka sebagai angka. Aplikasi nyata menuntut lebih — nomor kartu tidak boleh tersimpan polos, sandi pengguna tidak boleh bisa dibaca admin sekalipun, dan webhook masuk perlu dibuktikan keasliannya. Semua itu biasanya dikerjakan library kriptografi di sisi aplikasi, tetapi PostgreSQL punya jawakan sendiri: extension pgcrypto membawa fungsi hash, hash berkunci, byte acak, hashing password, sampai enkripsi PGP langsung ke dalam SQL. Satu CREATE EXTENSION, dan operasi kriptografi menjadi bagian dari query — bisa dipakai di DEFAULT, di trigger, di generated column, tanpa keluar dari database. Chapter ini membedah fungsi-fungsinya satu per satu beserta batas pemakaiannya.

Memasang pgcrypto dan Hash digest() #

Fungsi paling dasar pgcrypto adalah digest(): menghitung hash satu arah dari teks input. Hash bersifat deterministik — input sama menghasilkan hash sama — tapi tidak bisa dibalik ke teks aslinya, dan perubahan sekecil apa pun di input mengubah seluruh hash. Fungsi ini mengembalikan bytea; pasangan encode(..., 'hex') mengubahnya ke teks heksadesimal yang enak dibaca:

-- pgcrypto adalah trusted extension: pemasangannya satu baris.
CREATE EXTENSION pgcrypto;

-- digest() menghitung hash sebagai bytea; encode() mengubahnya ke heksadesimal.
SELECT encode(digest('sandi rahasia', 'sha256'), 'hex') AS sha256_hex;

-- Input yang sama selalu menghasilkan hash yang sama.
SELECT encode(digest('sandi rahasia', 'sha256'), 'hex')
     = encode(digest('sandi rahasia', 'sha256'), 'hex') AS selalu_sama;

-- Satu karakter berbeda menghasilkan hash yang sama sekali berbeda.
SELECT encode(digest('sandi rahasia', 'sha256'), 'hex')
     = encode(digest('sandi rahasia.', 'sha256'), 'hex') AS input_berubah;

Output yang Dihasilkan:

text
CREATE EXTENSION
                            sha256_hex                            
------------------------------------------------------------------
 048fe51c93d86dd4352a0e6807a73ec4a1ec6439412cf5c93755f26abcf2896b
(1 row)

 selalu_sama 
-------------
 t
(1 row)

 input_berubah 
---------------
 f
(1 row)

Tiga hasil dalam output itu adalah seluruh kontrak digest(). Hash 64 karakter heksadesimal (256 bit) keluar dari teks 'sandi rahasia'; pemanggilan kedua dengan input yang persis sama menghasilkan hash yang identik (t); dan menambahkan satu titik di akhir input mengubah hash total (f). Properti inilah yang membuat hash cocok untuk sidik jari data — mendeteksi duplikat, checksum ETL, dan pembanding isi — bukan untuk penyimpanan rahasia yang perlu dibaca kembali.

Algoritma yang didukung digest() antara lain md5, sha1, sha224, sha256, sha384, dan sha512. Namanya harus tepat; salah ketik ditolak dengan error yang cukup jelas:

-- Nama algoritma harus cocok dengan daftar yang didukung pgcrypto.
SELECT encode(digest('data', 'sha123'), 'hex') AS hash;

Output yang Dihasilkan:

text
psql:error-algoritme.sql:2: ERROR:  Cannot use "sha123": No such hash algorithm

HMAC: Hash Berkunci #

digest() punya satu kelemahan: siapa pun yang tahu teks aslinya bisa menghitung hash yang sama, sehingga hash saja tidak membuktikan siapa pembuatnya. hmac() menutup celah itu dengan menambahkan kunci rahasia ke dalam perhitungan — hanya pihak yang memegang kunci yang bisa menghasilkan nilai yang sama. Pola pakainya klasik: pengirim webhook mengirim pesan plus hmac(pesan, kunci_bersama), penerima menghitung ulang dan membandingkan:

sql hmac.sql
-- hmac: hash berkunci untuk memverifikasi keaslian pesan dari pengirim yang sama.
SELECT encode(hmac('pesan penting', 'kunci-rahasia', 'sha256'), 'hex') AS hmac_asli;

-- Kunci berbeda menghasilkan HMAC berbeda meski pesannya sama.
SELECT encode(hmac('pesan penting', 'kunci-rahasia', 'sha256'), 'hex')
     = encode(hmac('pesan penting', 'kunci-salah', 'sha256'), 'hex') AS kunci_berbeda;

-- Pesan berbeda dengan kunci sama juga menghasilkan HMAC berbeda.
SELECT encode(hmac('pesan penting', 'kunci-rahasia', 'sha256'), 'hex')
     = encode(hmac('pesan diubah', 'kunci-rahasia', 'sha256'), 'hex') AS pesan_berubah;

Output yang Dihasilkan:

text
                            hmac_asli                             
------------------------------------------------------------------
 5ecf933018a1df70bb120d5c4143df949d6cb4382b87695826d710c771911919
(1 row)

 kunci_berbeda 
---------------
 f
(1 row)

 pesan_berubah 
---------------
 f
(1 row)

Dua perbandingan berikutnya di output memastikan kedua sifat sekaligus: kunci yang salah (f) dan pesan yang diubah (f) sama-sama gagal dicocokkan. Berbeda dengan digest(), mengetahui teks pesan tidak cukup untuk memalsukan HMAC — penyerang harus juga mengetahui kuncinya. Karena itu kunci HMAC tidak pernah disimpan di kolom yang sama dengan data yang diverifikasinya.

Byte Acak: gen_random_bytes() #

Fungsi gen_random_bytes(n) menghasilkan n byte acak kriptografis — bahan baku token API, salt, dan initialization vector. Berbeda dari random() yang bisa di-seed lewat setseed(), fungsi ini memakai generator internal server yang tidak bisa di-seed; nilai hasilnya berbeda pada setiap eksekusi tanpa kecuali:

sql acak.sql
-- gen_random_bytes menghasilkan byte acak kriptografis.
-- Panjang hasilnya tetap sesuai argumen; nilainya berbeda setiap panggilan,
-- sehingga contoh hanya menampilkan panjang hasil, bukan isinya.
SELECT length(gen_random_bytes(16)) AS panjang_byte;
SELECT length(gen_random_bytes(32)) AS panjang_byte;

-- Representasi heksadesimal selalu dua karakter untuk setiap byte.
SELECT length(encode(gen_random_bytes(16), 'hex')) AS panjang_hex;

Output yang Dihasilkan:

text
 panjang_byte 
--------------
           16
(1 row)

 panjang_byte 
--------------
           32
(1 row)

 panjang_hex 
-------------
          32
(1 row)

Hash Password dengan crypt() dan gen_salt() #

Kebutuhan hashing password berbeda dari hashing data biasa: hash-nya harus lambat secara sengaja agar brute force tidak murah, dan menyimpan salt acak agar dua pengguna bersandi sama tetap punya hash berbeda. Pasangan crypt() + gen_salt() milik pgcrypto dirancang persis untuk itu. gen_salt('bf', 8) membuat salt untuk algoritme Blowfish (bf) dengan cost factor 8; salt itu ikut tertanam di hasil crypt(), sehingga verifikasi nanti tidak perlu menyimpan salt terpisah.

Ada satu hambatan untuk demo yang deterministik: gen_salt() menghasilkan salt acak, jadi hash yang dihasilkan berbeda setiap eksekusi. Solusinya: hash dibuat sekali di luar materi, lalu nilainya dipakai sebagai konstanta. Hash berikut adalah hasil nyata crypt('rahasia', gen_salt('bf', 8)) yang dibekukan sebagai contoh:

sql sandi.sql
-- Hash contoh berikut dibuat lebih dulu lewat crypt('rahasia', gen_salt('bf', 8)).
-- gen_salt menghasilkan salt acak, jadi hash yang sama tak bisa dibuat ulang;
-- hash ini disalin sebagai konstanta agar contoh tetap deterministik.
SELECT '$2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86' AS hash_tersimpan;

-- Verifikasi sandi: crypt() membaca salt yang tertanam di hash tersimpan,
-- menghitung ulang, lalu membandingkan hasilnya.
SELECT crypt('rahasia', '$2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86')
     = '$2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86' AS sandi_benar;

SELECT crypt('tebakan', '$2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86')
     = '$2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86' AS sandi_salah;

-- Pola tabel: hash dibuat sekali saat INSERT, diverifikasi lewat perbandingan.
CREATE TABLE pengguna (
    nama        text PRIMARY KEY,
    sandi_hash  text NOT NULL
);

INSERT INTO pengguna (nama, sandi_hash)
VALUES ('budi', crypt('rahasia2026', gen_salt('bf', 8))),
       ('siti', crypt('kunci-akses', gen_salt('bf', 8)));

-- Percobaan login: hasil selalu boolean, nilai hash acaknya tak pernah tampil.
SELECT nama,
       crypt('rahasia2026', sandi_hash) = sandi_hash AS sandi_benar,
       crypt('kunci-akses', sandi_hash) = sandi_hash AS sandi_tertukar
FROM pengguna
ORDER BY nama;

Output yang Dihasilkan:

text
                        hash_tersimpan                        
--------------------------------------------------------------
 $2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86
(1 row)

 sandi_benar 
-------------
 t
(1 row)

 sandi_salah 
-------------
 f
(1 row)

CREATE TABLE
INSERT 0 2
 nama | sandi_benar | sandi_tertukar 
------+-------------+----------------
 budi | t           | f
 siti | f           | t
(2 rows)

Mari kita bedah anatomi string hash $2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86:

  1. $2a$ — pengenal algoritme: Blowfish/bcrypt varian 2a.
  2. 08 — cost factor: jumlah iterasi dihitung sebagai 2^8 = 256 putaran. Menaikkan angka ini melipatgandakan waktu verifikasi — seluruh harga dari “hash yang sengaja lambat”.
  3. DHESz1XNDskeyt9ubrMt9e1 (22 karakter) — salt acak base64 yang dibuat gen_salt() saat hash dibuat.
  4. sSY/aHyCNhsvW4MHKCxyEylNx1b.86 (31 karakter) — hash sandinya sendiri.

Anatomi itu menjelaskan mengapa verifikasi memanggil crypt(sandi_baru, hash_tersimpan): argumen kedua bukan sekadar pembanding, melainkan sumber salt dan parameter — crypt() mengekstrak $2a$08$ plus salt, menghitung ulang dengan sandi yang dimasukkan pengguna, lalu hasilnya dibandingkan dengan hash tersimpan. Benar menghasilkan t, salah menghasilkan f. Pada contoh tabel, baris budi menunjukkan sandi benar untuk pemiliknya (t, f), dan baris siti kebalikannya (f, t) — sandi yang benar untuk pengguna yang salah sama saja ditolak.

Perlu digarisbawahi: crypt() untuk sandi milik aplikasi Anda yang tersimpan di tabel. Sandi untuk login ke PostgreSQL sendiri memakai mekanisme SCRAM yang dibahas tuntas di chapter Authentication — server menyimpan verifier SCRAM-nya sendiri, bukan hash crypt().

Enkripsi Dua Arah dengan PGP #

Hash tidak bisa dibalik — bagus untuk sandi, tidak untuk data yang harus dibaca kembali seperti nomor rekening. Untuk itu pgcrypto menyediakan pgp_sym_encrypt() dan pgp_sym_decrypt(): enkripsi simetris format OpenPGP yang menyandikan teks dengan satu kunci rahasia. Ciphertext-nya acak setiap eksekusi (OpenPGP menambahkan salt dan IV acak), sehingga contoh berikut hanya menampilkan hasil dekripsi yang deterministik:

sql pgp.sql
-- Enkripsi simetris dua arah: ciphertext acak, teks asli kembali utuh.
-- Karena ciphertext berubah setiap eksekusi, contoh hanya menampilkan
-- hasil dekripsi yang deterministik.
SELECT pgp_sym_decrypt(pgp_sym_encrypt('data penting', 'kunci-rahasia'),
                       'kunci-rahasia') AS hasil_dekripsi;

-- Algoritma cipher dan kompresi bisa dipilih lewat options.
SELECT pgp_sym_decrypt(
           pgp_sym_encrypt('laporan penjualan triwulan', 'kunci-rahasia',
                           'cipher-algo=aes256, compress-algo=0'),
           'kunci-rahasia') AS hasil_aes256;

Output yang Dihasilkan:

text
 hasil_dekripsi 
----------------
 data penting
(1 row)

        hasil_aes256        
----------------------------
 laporan penjualan triwulan
(1 row)

Kedua roundtrip mengembalikan teks aslinya — yang pertama dengan pengaturan default, yang kedua dengan cipher-algo=aes256 dan tanpa kompresi lewat parameter options. Kunci yang salah tidak mengembalikan teks asli: dekripsi gagal. Kelemahan pola ini juga harus diucapkan jujur: kunci rahasia tercantum di query itu sendiri, sehingga query log, view definition, dan statistik statement bisa ikut merekamnya. Enkripsi di database paling cocok bila kuncinya diinjeksi lewat aplikasi pada saat pemanggilan — bukan ditulis permanen di objek database.

Hash di Database vs di Aplikasi #

Dengan pgcrypto terpasang, kapan memilih database dan kapan tetap di aplikasi? Tabel berikut merangkum pertimbangan yang paling sering menentukan:

KebutuhanDi database (pgcrypto)Di aplikasi (library bcrypt/argon2)
Verifikasi sandi tabel aplikasicrypt() di query loginHash di kode, sandi tak pernah keluar dari aplikasi
Konsistensi lintas klien & lintas bahasaSatu definisi untuk semuaTiap stack menulis ulang logika yang sama
Audit hash otomatis (trigger, generated column)Bisa — berjalan di dalam serverHarus selalu lewat jalur aplikasi
Rotasi algoritme & kebijakanIkut siklus upgrade serverFleksibel: deploy aplikasi kapan saja
Jarak sandi polos dari memori serverSandi singgah di proses PostgreSQLSandi berhenti di proses aplikasi

Batas terakhir di tabel itu yang paling sering jadi pertimbangan keamanan serius: memverifikasi sandi di database berarti teks polosnya sempat berada di server PostgreSQL — di query, di log bila log_statement lupa dibatasi. Banyak tim keamanan memilih hashing tetap di aplikasi karena alasan itu, dan memakai pgcrypto justru untuk kasus yang butuh konsistensi lintas klien atau otomatisasi trigger.

Kriptografi di dalam SQL kini lengkap di tangan kita. Bergeser ke kebutuhan yang lebih ringan namun sama lazimnya: menghasilkan identifier unik untuk baris baru. Chapter berikutnya membedah gen_random_uuid() bawaan versus extension uuid-ossp — kapan cukup yang pertama, dan apa yang ditawarkan yang kedua.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

acak.sql

Unduh berkas
sql
-- gen_random_bytes menghasilkan byte acak kriptografis.
-- Panjang hasilnya tetap sesuai argumen; nilainya berbeda setiap panggilan,
-- sehingga contoh hanya menampilkan panjang hasil, bukan isinya.
SELECT length(gen_random_bytes(16)) AS panjang_byte;
SELECT length(gen_random_bytes(32)) AS panjang_byte;

-- Representasi heksadesimal selalu dua karakter untuk setiap byte.
SELECT length(encode(gen_random_bytes(16), 'hex')) AS panjang_hex;

error-algoritme.sql

Unduh berkas
sql
-- Nama algoritma harus cocok dengan daftar yang didukung pgcrypto.
SELECT encode(digest('data', 'sha123'), 'hex') AS hash;

hmac.sql

Unduh berkas
sql
-- hmac: hash berkunci untuk memverifikasi keaslian pesan dari pengirim yang sama.
SELECT encode(hmac('pesan penting', 'kunci-rahasia', 'sha256'), 'hex') AS hmac_asli;

-- Kunci berbeda menghasilkan HMAC berbeda meski pesannya sama.
SELECT encode(hmac('pesan penting', 'kunci-rahasia', 'sha256'), 'hex')
     = encode(hmac('pesan penting', 'kunci-salah', 'sha256'), 'hex') AS kunci_berbeda;

-- Pesan berbeda dengan kunci sama juga menghasilkan HMAC berbeda.
SELECT encode(hmac('pesan penting', 'kunci-rahasia', 'sha256'), 'hex')
     = encode(hmac('pesan diubah', 'kunci-rahasia', 'sha256'), 'hex') AS pesan_berubah;

pasang-dan-digest.sql

Unduh berkas
sql
-- pgcrypto adalah trusted extension: pemasangannya satu baris.
CREATE EXTENSION pgcrypto;

-- digest() menghitung hash sebagai bytea; encode() mengubahnya ke heksadesimal.
SELECT encode(digest('sandi rahasia', 'sha256'), 'hex') AS sha256_hex;

-- Input yang sama selalu menghasilkan hash yang sama.
SELECT encode(digest('sandi rahasia', 'sha256'), 'hex')
     = encode(digest('sandi rahasia', 'sha256'), 'hex') AS selalu_sama;

-- Satu karakter berbeda menghasilkan hash yang sama sekali berbeda.
SELECT encode(digest('sandi rahasia', 'sha256'), 'hex')
     = encode(digest('sandi rahasia.', 'sha256'), 'hex') AS input_berubah;

pgp.sql

Unduh berkas
sql
-- Enkripsi simetris dua arah: ciphertext acak, teks asli kembali utuh.
-- Karena ciphertext berubah setiap eksekusi, contoh hanya menampilkan
-- hasil dekripsi yang deterministik.
SELECT pgp_sym_decrypt(pgp_sym_encrypt('data penting', 'kunci-rahasia'),
                       'kunci-rahasia') AS hasil_dekripsi;

-- Algoritma cipher dan kompresi bisa dipilih lewat options.
SELECT pgp_sym_decrypt(
           pgp_sym_encrypt('laporan penjualan triwulan', 'kunci-rahasia',
                           'cipher-algo=aes256, compress-algo=0'),
           'kunci-rahasia') AS hasil_aes256;

sandi.sql

Unduh berkas
sql
-- Hash contoh berikut dibuat lebih dulu lewat crypt('rahasia', gen_salt('bf', 8)).
-- gen_salt menghasilkan salt acak, jadi hash yang sama tak bisa dibuat ulang;
-- hash ini disalin sebagai konstanta agar contoh tetap deterministik.
SELECT '$2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86' AS hash_tersimpan;

-- Verifikasi sandi: crypt() membaca salt yang tertanam di hash tersimpan,
-- menghitung ulang, lalu membandingkan hasilnya.
SELECT crypt('rahasia', '$2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86')
     = '$2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86' AS sandi_benar;

SELECT crypt('tebakan', '$2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86')
     = '$2a$08$DHESz1XNDskeyt9ubrMt9e1sSY/aHyCNhsvW4MHKCxyEylNx1b.86' AS sandi_salah;

-- Pola tabel: hash dibuat sekali saat INSERT, diverifikasi lewat perbandingan.
CREATE TABLE pengguna (
    nama        text PRIMARY KEY,
    sandi_hash  text NOT NULL
);

INSERT INTO pengguna (nama, sandi_hash)
VALUES ('budi', crypt('rahasia2026', gen_salt('bf', 8))),
       ('siti', crypt('kunci-akses', gen_salt('bf', 8)));

-- Percobaan login: hasil selalu boolean, nilai hash acaknya tak pernah tampil.
SELECT nama,
       crypt('rahasia2026', sandi_hash) = sandi_hash AS sandi_benar,
       crypt('kunci-akses', sandi_hash) = sandi_hash AS sandi_tertukar
FROM pengguna
ORDER BY nama;