Crypto #

Keamanan kriptografis adalah fondasi dari hampir setiap sistem yang menangani data sensitif — menyimpan password, memverifikasi integritas data, mengamankan komunikasi, atau menghasilkan token yang tidak bisa ditebak. Node.js menyediakan modul crypto bawaan yang mengimplementasikan algoritma kriptografis standar industri tanpa perlu library eksternal. Memahami kapan menggunakan hashing, HMAC, enkripsi simetris, atau password hashing — dan lebih penting lagi, kapan tidak menggunakan masing-masing — adalah keterampilan yang langsung berdampak pada keamanan aplikasi yang kamu bangun.

Modul crypto Node.js #

import crypto from "crypto";

// atau import fungsi spesifik
import {
  createHash,
  createHmac,
  createCipheriv,
  createDecipheriv,
  randomBytes,
  randomUUID,
  scrypt,
  timingSafeEqual,
} from "crypto";

Modul crypto Node.js menggunakan OpenSSL di bawahnya — semua algoritma yang tersedia di instalasi OpenSSL sistem bisa digunakan. Untuk melihat daftar algoritma yang tersedia:

// daftar semua algoritma hash yang tersedia
const algoritmHash = crypto.getHashes();
console.log(algoritmHash);
// ["md5", "sha1", "sha256", "sha384", "sha512", "sha3-256", ...]

// daftar semua algoritma cipher yang tersedia
const algoritmCipher = crypto.getCiphers();
console.log(algoritmCipher);
// ["aes-128-cbc", "aes-256-gcm", "chacha20-poly1305", ...]

Hashing #

Hash adalah fungsi satu arah — mengubah data dengan panjang sembarang menjadi string dengan panjang tetap. Tidak bisa dibalik. Digunakan untuk memverifikasi integritas data, bukan untuk enkripsi.

flowchart LR
    A["Data asli\n'Hello World'"] --> B[Hash Function\nSHA-256]
    B --> C["Hash\na591a6d10b..."]
    D["Data berubah\n'Hello World!'"] --> B
    B --> E["Hash berbeda\n2d31a09f3c..."]
    
    style C fill:#16a34a,color:#fff
    style E fill:#dc2626,color:#fff

SHA-256 dan SHA-512 #

SHA-256 dan SHA-512 adalah algoritma hash yang direkomendasikan untuk penggunaan umum — checksum file, fingerprint data, ID deterministik.

// hash string dengan SHA-256
function hashSHA256(data: string): string {
  return crypto.createHash("sha256").update(data, "utf-8").digest("hex");
}

// hash string dengan SHA-512
function hashSHA512(data: string): string {
  return crypto.createHash("sha512").update(data, "utf-8").digest("hex");
}

console.log(hashSHA256("Hello, World!"));
// "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986d"

// hash selalu menghasilkan output yang sama untuk input yang sama
console.log(hashSHA256("Hello, World!") === hashSHA256("Hello, World!")); // true

// satu karakter berbeda menghasilkan hash yang sangat berbeda (avalanche effect)
console.log(hashSHA256("Hello, World!"));
// "dffd6021..."
console.log(hashSHA256("Hello, World?")); // tanda tanya, bukan tanda seru
// "f2d9e458..." — sama sekali berbeda

// hash bisa dalam format berbeda
function hashDenganFormat(
  data: string,
  algoritma: string = "sha256",
  format: "hex" | "base64" | "base64url" = "hex"
): string {
  return crypto.createHash(algoritma).update(data, "utf-8").digest(format);
}

console.log(hashDenganFormat("data", "sha256", "hex"));
// "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"

console.log(hashDenganFormat("data", "sha256", "base64"));
// "Om6weQ85rIfJTzhWst0sXREOaBFgImGpqSPTuyrty7c="

// hash Buffer (untuk file biner)
function hashBuffer(data: Buffer, algoritma: string = "sha256"): string {
  return crypto.createHash(algoritma).update(data).digest("hex");
}

// hash file secara streaming — untuk file besar
import { createReadStream } from "fs";

async function hashFile(filePath: string, algoritma: string = "sha256"): Promise<string> {
  return new Promise((resolve, reject) => {
    const hash = crypto.createHash(algoritma);
    const stream = createReadStream(filePath);

    stream.on("data", (chunk) => hash.update(chunk));
    stream.on("end", () => resolve(hash.digest("hex")));
    stream.on("error", reject);
  });
}

// verifikasi integritas file download
async function verifikasiFile(
  filePath: string,
  hashYangDiharapkan: string,
  algoritma: string = "sha256"
): Promise<boolean> {
  const hashAktual = await hashFile(filePath, algoritma);
  // BENAR: gunakan timingSafeEqual untuk perbandingan hash
  // mencegah timing attack
  const bufA = Buffer.from(hashAktual, "hex");
  const bufB = Buffer.from(hashYangDiharapkan, "hex");
  return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
}
Jangan gunakan MD5 atau SHA-1 untuk keperluan keamanan — keduanya sudah dianggap tidak aman dan rentan terhadap collision attack. Gunakan SHA-256 atau lebih tinggi. MD5 masih boleh digunakan untuk non-security purpose seperti checksum sederhana atau cache key, tapi bukan untuk memverifikasi keaslian data dari sumber yang tidak dipercaya.

HMAC — Hash dengan Kunci Rahasia #

HMAC (Hash-based Message Authentication Code) menggabungkan hash dengan kunci rahasia. Digunakan untuk memverifikasi bahwa pesan tidak dimodifikasi dan berasal dari pihak yang memegang kunci — berbeda dari hash biasa yang hanya memverifikasi integritas tanpa autentikasi.

sequenceDiagram
    participant Pengirim
    participant Penerima

    Pengirim->>Pengirim: HMAC(pesan + kunci_rahasia)
    Pengirim->>Penerima: pesan + signature
    Penerima->>Penerima: HMAC(pesan + kunci_rahasia)
    Penerima->>Penerima: bandingkan signature
    alt Signature cocok
        Penerima->>Penerima: ✓ pesan autentik dan tidak dimodifikasi
    else Signature berbeda
        Penerima->>Penerima: ✗ pesan dimodifikasi atau bukan dari pengirim yang sah
    end
// buat HMAC
function buatHMAC(data: string, kunci: string, algoritma: string = "sha256"): string {
  return crypto.createHmac(algoritma, kunci).update(data, "utf-8").digest("hex");
}

// verifikasi HMAC — WAJIB gunakan timingSafeEqual
function verifikasiHMAC(
  data: string,
  kunci: string,
  hmacYangDiterima: string,
  algoritma: string = "sha256"
): boolean {
  const hmacYangDihitung = buatHMAC(data, kunci, algoritma);

  // ANTI-PATTERN: perbandingan string biasa — rentan timing attack
  // return hmacYangDihitung === hmacYangDiterima; ✗

  // BENAR: timingSafeEqual — waktu perbandingan selalu konstan
  const bufA = Buffer.from(hmacYangDihitung, "hex");
  const bufB = Buffer.from(hmacYangDiterima, "hex");

  // panjang harus sama dulu sebelum timingSafeEqual
  if (bufA.length !== bufB.length) return false;
  return crypto.timingSafeEqual(bufA, bufB); // ✓
}

const kunci = process.env.HMAC_SECRET_KEY!;
const payload = JSON.stringify({ userId: 123, action: "transfer", jumlah: 500000 });

const signature = buatHMAC(payload, kunci);
console.log("Signature:", signature);

const valid = verifikasiHMAC(payload, kunci, signature);
console.log("Valid:", valid); // true

// payload dimodifikasi
const payloadDimodifikasi = JSON.stringify({ userId: 123, action: "transfer", jumlah: 999999 });
const validDimodifikasi = verifikasiHMAC(payloadDimodifikasi, kunci, signature);
console.log("Valid (dimodifikasi):", validDimodifikasi); // false

Signed URL — URL yang Tidak Bisa Dipalsukan #

// buat URL dengan tanda tangan yang kedaluwarsa otomatis
function buatSignedURL(
  basePath: string,
  params: Record<string, string>,
  kunci: string,
  ttlDetik: number = 3600
): string {
  const kedaluwarsa = Math.floor(Date.now() / 1000) + ttlDetik;
  const semuaParam = { ...params, expires: String(kedaluwarsa) };

  // urutkan parameter agar signature deterministik
  const queryString = Object.entries(semuaParam)
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
    .join("&");

  const dataUntukSign = `${basePath}?${queryString}`;
  const signature = buatHMAC(dataUntukSign, kunci, "sha256").slice(0, 32);

  return `${dataUntukSign}&sig=${signature}`;
}

function verifikasiSignedURL(url: string, kunci: string): boolean {
  const [pathDanQuery, sigPart] = url.split("&sig=");
  if (!sigPart) return false;

  // cek kedaluwarsa
  const params = new URLSearchParams(pathDanQuery.split("?")[1]);
  const expires = Number(params.get("expires"));
  if (Date.now() / 1000 > expires) return false; // sudah kedaluwarsa

  // verifikasi signature
  const signatureYangDiharapkan = buatHMAC(pathDanQuery, kunci, "sha256").slice(0, 32);
  const bufA = Buffer.from(sigPart);
  const bufB = Buffer.from(signatureYangDiharapkan);
  return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
}

Enkripsi Simetris — AES #

Enkripsi berbeda dari hashing — data yang dienkripsi bisa didekripsi kembali dengan kunci yang sama. AES-256-GCM adalah mode enkripsi yang direkomendasikan karena menyediakan enkripsi sekaligus autentikasi integritas (authenticated encryption).

flowchart LR
    A["Plaintext\n'data rahasia'"] -- "Kunci + IV" --> B[AES-256-GCM]
    B --> C["Ciphertext\n+ Auth Tag"]
    C -- "Kunci + IV\n+ Auth Tag" --> D[Dekripsi]
    D --> E["Plaintext\n'data rahasia'"]
    
    style A fill:#374151,color:#fff
    style E fill:#374151,color:#fff
    style C fill:#1e40af,color:#fff
const ALGORITMA = "aes-256-gcm";
const PANJANG_KUNCI = 32; // 256 bit
const PANJANG_IV = 16;    // 128 bit — ukuran standar untuk GCM

interface HasilEnkripsi {
  ciphertext: string; // data terenkripsi dalam hex
  iv: string;         // initialization vector dalam hex
  authTag: string;    // authentication tag dalam hex
}

// enkripsi data
function enkripsi(plaintext: string, kunci: Buffer): HasilEnkripsi {
  // IV harus unik untuk setiap enkripsi — jangan pernah pakai ulang IV yang sama
  const iv = crypto.randomBytes(PANJANG_IV);

  const cipher = crypto.createCipheriv(ALGORITMA, kunci, iv);

  const encrypted = Buffer.concat([
    cipher.update(plaintext, "utf-8"),
    cipher.final(),
  ]);

  // ambil authentication tag — penting untuk verifikasi saat dekripsi
  const authTag = cipher.getAuthTag();

  return {
    ciphertext: encrypted.toString("hex"),
    iv: iv.toString("hex"),
    authTag: authTag.toString("hex"),
  };
}

// dekripsi data
function dekripsi(hasil: HasilEnkripsi, kunci: Buffer): string {
  const decipher = crypto.createDecipheriv(
    ALGORITMA,
    kunci,
    Buffer.from(hasil.iv, "hex")
  );

  // set authentication tag sebelum dekripsi — GCM akan verifikasi integritas
  decipher.setAuthTag(Buffer.from(hasil.authTag, "hex"));

  const decrypted = Buffer.concat([
    decipher.update(Buffer.from(hasil.ciphertext, "hex")),
    decipher.final(), // melempar error jika authTag tidak cocok
  ]);

  return decrypted.toString("utf-8");
}

// derive kunci dari password menggunakan scrypt
async function deriveKunci(password: string, salt: Buffer): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    crypto.scrypt(password, salt, PANJANG_KUNCI, (err, derivedKey) => {
      if (err) reject(err);
      else resolve(derivedKey);
    });
  });
}

// contoh penggunaan end-to-end
async function contohEnkripsiDekripsi(): Promise<void> {
  const password = "password-rahasia-yang-kuat";
  const salt = crypto.randomBytes(32); // simpan salt bersama data terenkripsi

  const kunci = await deriveKunci(password, salt);

  const dataRahasia = JSON.stringify({
    kartuKredit: "4111-1111-1111-1111",
    cvv: "123",
    kedaluwarsa: "12/26",
  });

  // enkripsi
  const terenkripsi = enkripsi(dataRahasia, kunci);
  console.log("Terenkripsi:", terenkripsi);

  // dekripsi
  const terdekripsi = dekripsi(terenkripsi, kunci);
  console.log("Terdekripsi:", terdekripsi);
}
Jangan pernah pakai ulang IV (Initialization Vector) dengan kunci yang sama. Setiap operasi enkripsi harus menggunakan IV baru yang di-generate secara acak. Menggunakan IV yang sama dua kali dengan kunci yang sama menghancurkan keamanan enkripsi sepenuhnya — penyerang bisa memulihkan plaintext dengan XOR kedua ciphertext.

Token Acak yang Aman #

Untuk token yang digunakan dalam konteks keamanan — session token, link reset password, OTP, API key — selalu gunakan crypto.randomBytes(), bukan Math.random().

// ANTI-PATTERN: token dari Math.random() — tidak aman secara kriptografis
function tokenTidakAman(): string {
  return Math.random().toString(36).slice(2); // ✗ bisa diprediksi
}

// BENAR: token dari crypto.randomBytes()
function generateToken(panjangBytes: number = 32): string {
  return crypto.randomBytes(panjangBytes).toString("hex");
  // 32 bytes = 64 karakter hex = 256 bit entropy
}

function generateTokenBase64URL(panjangBytes: number = 32): string {
  return crypto.randomBytes(panjangBytes).toString("base64url");
  // base64url aman untuk URL — tanpa karakter +, /, =
}

// UUID v4 — identifier unik universal
function generateUUID(): string {
  return crypto.randomUUID();
  // "550e8400-e29b-41d4-a716-446655440000"
}

// OTP numerik N digit
function generateOTP(panjang: number = 6): string {
  // randomBytes menghasilkan nilai 0-255 per byte
  // untuk OTP numerik, ambil modulo 10 per digit
  const bytes = crypto.randomBytes(panjang);
  return Array.from(bytes)
    .map((b) => b % 10)
    .join("");
}

console.log(generateOTP(6));  // misal: "847261"
console.log(generateOTP(8));  // misal: "37291046"

// API key dengan format yang mudah dibaca: prefix + token
function generateAPIKey(prefix: string = "sk"): string {
  const token = crypto.randomBytes(24).toString("base64url");
  return `${prefix}_${token}`;
}

console.log(generateAPIKey("sk"));  // misal: "sk_a3mK9pLx..."
console.log(generateAPIKey("pk"));  // misal: "pk_7nRt2yBq..."

// token dengan kedaluwarsa
interface TokenDenganExpiry {
  token: string;
  kedaluwarsa: Date;
}

function generateTokenDenganExpiry(ttlMenit: number = 60): TokenDenganExpiry {
  const token = generateToken(32);
  const kedaluwarsa = new Date(Date.now() + ttlMenit * 60 * 1000);
  return { token, kedaluwarsa };
}

// simpan hash token di database, bukan token aslinya
// sehingga jika database bocor, token tidak bisa langsung digunakan
function hashToken(token: string): string {
  return crypto.createHash("sha256").update(token).digest("hex");
}

async function buatTokenResetPassword(userId: string): Promise<string> {
  const { token, kedaluwarsa } = generateTokenDenganExpiry(30); // 30 menit
  const hashTokenReset = hashToken(token);

  // simpan hash ke database
  await db.simpan("password_reset_tokens", {
    userId,
    tokenHash: hashTokenReset,
    kedaluwarsa,
  });

  // kembalikan token asli untuk dikirim ke email pengguna
  return token;
}

async function verifikasiTokenReset(token: string): Promise<string | null> {
  const hashTokenInput = hashToken(token);

  const record = await db.cari("password_reset_tokens", {
    tokenHash: hashTokenInput,
    kedaluwarsa: { $gt: new Date() }, // belum kedaluwarsa
  });

  return record?.userId ?? null;
}

// placeholder untuk db — implementasi sesuai database yang digunakan
const db = {
  simpan: async (_collection: string, _data: unknown) => {},
  cari: async (_collection: string, _query: unknown) => null as any,
};

Password Hashing — scrypt dan bcrypt #

Password tidak boleh disimpan sebagai plaintext, dan tidak boleh di-hash dengan SHA-256 biasa. Gunakan algoritma yang dirancang khusus untuk password — lambat secara by design untuk memperlambat brute force attack.

scrypt — Bawaan Node.js #

interface HasilHashPassword {
  hash: string; // hash dalam hex
  salt: string; // salt dalam hex
}

// hash password baru
async function hashPassword(password: string): Promise<HasilHashPassword> {
  const salt = crypto.randomBytes(32); // salt unik per password

  return new Promise((resolve, reject) => {
    crypto.scrypt(
      password,
      salt,
      64,  // panjang output dalam bytes
      {
        N: 16384, // cost factor — semakin besar semakin lambat (default: 16384)
        r: 8,     // block size
        p: 1,     // parallelization factor
      },
      (err, derivedKey) => {
        if (err) reject(err);
        else resolve({
          hash: derivedKey.toString("hex"),
          salt: salt.toString("hex"),
        });
      }
    );
  });
}

// verifikasi password
async function verifikasiPassword(
  passwordInput: string,
  hashTersimpan: string,
  saltTersimpan: string
): Promise<boolean> {
  const salt = Buffer.from(saltTersimpan, "hex");

  return new Promise((resolve, reject) => {
    crypto.scrypt(passwordInput, salt, 64, { N: 16384, r: 8, p: 1 }, (err, derivedKey) => {
      if (err) reject(err);
      else {
        const hashInput = derivedKey;
        const hashDB = Buffer.from(hashTersimpan, "hex");

        // timingSafeEqual — mencegah timing attack
        if (hashInput.length !== hashDB.length) {
          resolve(false);
        } else {
          resolve(crypto.timingSafeEqual(hashInput, hashDB));
        }
      }
    });
  });
}

// contoh alur registrasi dan login
async function registrasi(email: string, password: string): Promise<void> {
  const { hash, salt } = await hashPassword(password);
  await db.simpan("users", { email, passwordHash: hash, passwordSalt: salt });
  console.log("User berhasil dibuat");
}

async function login(email: string, passwordInput: string): Promise<boolean> {
  const user = await db.cari("users", { email });
  if (!user) return false;

  return verifikasiPassword(passwordInput, user.passwordHash, user.passwordSalt);
}

bcrypt — Library Populer #

Untuk proyek yang sudah menggunakan bcrypt, berikut pola yang benar:

import bcrypt from "bcrypt";

// JANGAN diinstall jika belum perlu — scrypt bawaan Node.js sudah cukup
// npm install bcrypt
// npm install --save-dev @types/bcrypt

const SALT_ROUNDS = 12; // 10–14 adalah rentang yang umum digunakan

async function hashPasswordBcrypt(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
  // bcrypt mengelola salt secara internal — tidak perlu generate manual
}

async function verifikasiPasswordBcrypt(
  passwordInput: string,
  hashTersimpan: string
): Promise<boolean> {
  return bcrypt.compare(passwordInput, hashTersimpan);
  // bcrypt.compare sudah timing-safe secara internal
}
scrypt (bawaan)bcrypt (library)
InstalasiTidak perlunpm install bcrypt
Memory-hardnessYa (lebih kuat)Terbatas
Parameter kustomisasiLebih fleksibelHanya saltRounds
RekomendasiUntuk proyek baruUntuk proyek yang sudah pakai bcrypt

Timing-Safe Comparison #

Perbandingan string biasa (===) mengembalikan false segera saat menemukan karakter yang berbeda — ini bocorkan informasi waktu kepada penyerang tentang berapa banyak karakter yang sudah cocok.

// ANTI-PATTERN: perbandingan string biasa untuk nilai keamanan
function verifikasiTokenTidakAman(tokenInput: string, tokenSah: string): boolean {
  return tokenInput === tokenSah; // ✗ rentan timing attack
}

// BENAR: timingSafeEqual — selalu membandingkan seluruh string
function verifikasiTokenAman(tokenInput: string, tokenSah: string): boolean {
  // konversi ke Buffer dulu — timingSafeEqual hanya menerima Buffer
  const bufInput = Buffer.from(tokenInput, "utf-8");
  const bufSah = Buffer.from(tokenSah, "utf-8");

  // panjang harus sama — jika tidak, kembalikan false tapi tetap konstan waktu
  if (bufInput.length !== bufSah.length) {
    // XOR dengan dirinya sendiri agar tetap ada operasi yang butuh waktu konstan
    crypto.timingSafeEqual(bufSah, bufSah);
    return false;
  }

  return crypto.timingSafeEqual(bufInput, bufSah); // ✓
}

Kapan Menggunakan Apa #

Hash (SHA-256, SHA-512):
  ✓ Checksum / verifikasi integritas file
  ✓ Fingerprint data untuk cache key atau ID deterministik
  ✓ Menyimpan hash token di database (setelah token dikirim ke user)
  ✗ JANGAN untuk password — tidak punya salt dan terlalu cepat
  ✗ JANGAN untuk data yang perlu didekripsi kembali

HMAC:
  ✓ Verifikasi bahwa data tidak dimodifikasi DAN berasal dari pihak tertentu
  ✓ Signed URL, webhook signature, JWT signature
  ✓ Session token yang tidak disimpan di database (stateless)
  ✗ JANGAN untuk password

Enkripsi Simetris (AES-256-GCM):
  ✓ Data yang perlu didekripsi — nomor kartu kredit, data medis, konfigurasi rahasia
  ✓ Enkripsi file atau database field
  ✗ JANGAN untuk password — tidak ada alasan mendekripsi password
  ✗ JANGAN pakai ECB atau CBC tanpa autentikasi

Password Hashing (scrypt, bcrypt):
  ✓ SATU-SATUNYA pilihan untuk menyimpan password
  ✓ Hash PIN atau passphrase
  ✗ JANGAN untuk data selain password — terlalu lambat

Token Acak (crypto.randomBytes):
  ✓ Session token, API key, link reset password, OTP
  ✗ JANGAN gunakan Math.random() untuk keperluan keamanan

Ringkasan #

  • SHA-256 untuk hashing umum — checksum, fingerprint, cache key. Hindari MD5 dan SHA-1 untuk konteks keamanan karena sudah rentan collision.
  • HMAC untuk autentikasi pesan — membuktikan bahwa data tidak dimodifikasi dan berasal dari pihak yang memegang kunci rahasia. Selalu gunakan timingSafeEqual saat membandingkan HMAC.
  • AES-256-GCM untuk enkripsi — satu-satunya mode enkripsi yang direkomendasikan karena menggabungkan enkripsi dengan autentikasi integritas. Selalu generate IV baru untuk setiap operasi enkripsi.
  • scrypt atau bcrypt untuk password — jangan pernah simpan password sebagai plaintext atau hash SHA biasa. scrypt sudah tersedia di Node.js tanpa instalasi tambahan.
  • crypto.randomBytes() untuk semua token keamanan — session, API key, OTP, link reset. Math.random() tidak kriptografis dan bisa diprediksi.
  • Simpan hash token di database — jangan simpan token asli. Jika database bocor, hash tidak bisa langsung digunakan karena penyerang tidak tahu token aslinya.
  • timingSafeEqual untuk semua perbandingan nilai keamanan — mencegah timing attack yang bisa digunakan penyerang untuk menebak nilai secara karakter per karakter.
  • Derive kunci dari password dengan scrypt — jangan gunakan password langsung sebagai kunci enkripsi AES. Selalu gunakan key derivation function dengan salt unik.

← Sebelumnya: Math   Berikutnya: URL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact