Sistem autentikasi API berbasis request signing HMAC umumnya mewajibkan penyertaan timestamp pada header. Server memvalidasi timestamp tersebut terhadap jam internalnya menggunakan toleransi waktu ketat (misalnya ±5 menit) untuk membatasi window of vulnerability dari replay attack.
Pendekatan standar ini gagal saat berhadapan dengan client pada lingkungan perangkat keras vintage atau sistem operasi minimalis tanpa daemon NTP yang aktif—seperti SerenityOS pada laptop ThinkPad T60 lama dengan baterai CMOS habis. Mesin semacam ini sering kali melakukan booting dengan waktu sistem kembali ke UNIX epoch (1 Januari 1970) atau waktu rilis BIOS. Dampaknya, seluruh request valid langsung ditolak dengan status HTTP 401 Unauthorized akibat kegagalan validasi window timestamp.
Akar Masalah: Validasi Fixed Window
Pola HMAC standar membentuk signature dari canonical string:
CanonicalString = HTTP_METHOD + "\n" +
REQUEST_PATH + "\n" +
TIMESTAMP + "\n" +
NONCE + "\n" +
HEX(SHA256(REQUEST_BODY))
Signature = Base64(HMAC_SHA256(SecretKey, CanonicalString))Verifikasi timestamp di sisi server umumnya diimplementasikan sebagai berikut:
if math.Abs(float64(serverNow - clientTimestamp)) > 300 {
return ErrTimestampOutOfRange // 401 Unauthorized
}Dua masalah utama yang muncul:
- False-positive rejection: Jam lokal client yang mengalami desinkronisasi ribuan detik otomatis ditolak meskipun signature kriptografisnya valid.
- Risiko pembesaran window: Melonggarkan batas waktu (misalnya menjadi 24 jam) memaksa server menyimpan cache nonce jauh lebih lama untuk menangkal replay attack, yang memicu pemborosan memori Redis atau memory leak.
Desain Solusi: Mengatasi Drift Tanpa Mengorbankan Replay Protection
Solusi yang benar tidak memperbesar window timestamp pada server, melainkan menyinkronkan persepsi waktu antara client dan server secara transparan pada lapisan aplikasi.
1. Passive Time Discovery via Header Date HTTP
Protokol HTTP standar mengharuskan server menyertakan header Date pada setiap response (RFC 7231). Client dapat menghitung offset lokal tanpa protokol NTP terpisah:
- Client mengirimkan request unauthenticated (misalnya
HEAD /api/v1/time) atau mengambil headerDatedari response API apa pun sebelumnya. - Client mencatat:
ClientOffset = ServerUnixTimestamp - ClientSystemTime. - Setiap request berikutnya menyematkan
AdjustedTimestamp = ClientSystemTime + ClientOffsetpada headerX-Timestamp.
2. Ephemeral Challenge Nonce (Server-Driven Window)
Jika client sama sekali tidak memiliki basis clock yang monotonik (misalnya hardware reset tanpa persistence), server mengendalikan window validasi menggunakan token tantangan (nonce) berumur pendek:
- Client meminta token:
POST /api/v1/auth/challenge. - Server men-generate nonce acak kriptografis, menyimpannya di Redis dengan TTL pendek (misalnya 60 detik) yang dikaitkan dengan
client_id:SET auth_nonce:<client_id>:<nonce> "1" EX 60. - Client menggunakan nonce tersebut di canonical string HMAC.
- Server memverifikasi keberadaan nonce di Redis secara atomik via
DEL auth_nonce:<client_id>:<nonce>. Jika key ditemukan dan terhapus, request diproses. Jika nol, tolak (replay atau expired).
3. Bounded Dynamic Clock Drift Tracking
Bila handshake berkala membebani bandwidth jaringan yang terbatas, server melacak drift_offset per client pada cache. Pada registrasi/koneksi awal, server merekam deviasi waktu client. Deviasi ini diijinkan bergeser hanya dalam batas toleransi pergeseran osilator kristal kuarsa (maksimal beberapa detik per hari).
Implementasi Middleware di Go
Contoh middleware Go berikut menerapkan verifikasi signature HMAC-SHA256 dengan kombinasi deteksi waktu dan pencegahan replay berbasis Redis.
package middleware
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
type HMACConfig struct {
MaxDriftAllowed time.Duration
RedisClient *redis.Client
SecretProvider func(clientID string) ([]byte, error)
}
func HMACAuthMiddleware(cfg HMACConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
clientID := r.Header.Get("X-Client-ID")
clientTimestampStr := r.Header.Get("X-Timestamp")
nonce := r.Header.Get("X-Nonce")
providedSig := r.Header.Get("X-Signature")
if clientID == "" || clientTimestampStr == "" || nonce == "" || providedSig == "" {
http.Error(w, "Missing authentication headers", http.StatusUnauthorized)
return
}
clientUnix, err := strconv.ParseInt(clientTimestampStr, 10, 64)
if err != nil {
http.Error(w, "Invalid timestamp format", http.StatusBadRequest)
return
}
// Validasi window waktu server vs timestamp terkoreksi dari client
serverNow := time.Now().Unix()
drift := time.Duration(serverNow-clientUnix) * time.Second
if drift < 0 {
drift = -drift
}
if drift > cfg.MaxDriftAllowed {
// Berikan header Date agar client dapat memperbarui offset lokalnya
w.Header().Set("Date", time.Now().UTC().Format(http.TimeFormat))
http.Error(w, "Clock drift too large. Sync clock using Date header.", http.StatusUnauthorized)
return
}
// Cegah replay attack via Redis: SET key 1 NX EX ttl
nonceKey := fmt.Sprintf("nonce:%s:%s", clientID, nonce)
ok, err := cfg.RedisClient.SetNX(ctx, nonceKey, "1", cfg.MaxDriftAllowed*2).Result()
if err != nil || !ok {
http.Error(w, "Invalid or replayed nonce", http.StatusUnauthorized)
return
}
secret, err := cfg.SecretProvider(clientID)
if err != nil || len(secret) == 0 {
http.Error(w, "Client not found", http.StatusUnauthorized)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Cannot read body", http.StatusInternalServerError)
return
}
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
bodyHash := sha256.Sum256(bodyBytes)
bodyHashHex := hex.EncodeToString(bodyHash[:])
// Canonical representation
canonical := fmt.Sprintf("%s\n%s\n%s\n%s\n%s",
r.Method,
r.URL.Path,
clientTimestampStr,
nonce,
bodyHashHex,
)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(canonical))
expectedSig := hex.EncodeToString(mac.Sum(nil))
// Constant time comparison mencegah timing attack
if !hmac.Equal([]byte(providedSig), []byte(expectedSig)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}Implementasi Client Monotonic Drift Compensation (C / SerenityOS style)
Pada sisi client minimalis, modifikasi penentuan waktu dapat ditulis dalam logika C/C++ ringkas tanpa menyentuh jam kernel host:
static int64_t g_server_offset_sec = 0;
void update_sync_offset(int64_t server_http_date_epoch) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
g_server_offset_sec = server_http_date_epoch - ts.tv_sec;
}
int64_t get_adjusted_timestamp(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec + g_server_offset_sec;
}Menggunakan CLOCK_MONOTONIC memastikan bahwa jika waktu sistem diutak-atik atau RTC CMOS melompat, interval kalkulasi timestamp tetap linier dan terikat ke offset server yang telah dikalibrasi.
Trade-offs dan Pertimbangan Teknis
- Resource Nonce Cache: Redis wajib memiliki batas TTL untuk nonce yang setara dengan
MaxDriftAllowed * 2. NilaiMaxDriftAllowedsebesar 60-120 detik adalah kompromi aman jika sinkronisasi offset HTTP Date berjalan. - Cold Boot Client: Pada koneksi perdana setelah boot, client wajib membaca response header
Datesebelum mencoba mengirim authenticated request pertama. Desain client harus menangani401 Unauthorizedkhusus drift dengan memperbarui offset otomatis lalu melakukan retry secara transparan. - Kelemahan Man-in-the-Middle (MitM): Header
Datetidak terenkripsi jika koneksi tidak menggunakan TLS. Pastikan seluruh komunikasi berjalan di atas HTTPS guna mencegah manipulasi waktu oleh pihak ketiga.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!