S/

Pemrograman / 11 min read

Panduan Implementasi Autentikasi JWT & Nostr NIP-98

Satria Aji Putra
Satria Aji Putra Author
Panduan Implementasi Autentikasi JWT & Nostr NIP-98

Autentikasi merupakan hal yang paling krusial dalam pengembangan aplikasi seperti web agar data sensitif dari pengguna terdaftar dapat terjaga keamanannya. Di masa kini, authentication1 sudah banyak caranya, mulai dari OAuth, Username/Email + Password tradisional, kombinasi dengan JWT, dan sebagainya.

Dalam artikel ini, kita akan mempelajari cara membangun sistem autentikasi yang lebih aman dengan Nostr NIP-982. Meskipun Nostr dikenal sebagai protokol jejaring sosial yang terdesentralisasi, dalam panduan ini kita akan mengadaptasi mekanisme NIP-98 untuk sistem yang bersifat tersentralisasi (seperti aplikasi web tradisional). Menariknya, pendekatan ini tetap fleksibel; Anda bisa menggunakannya di sistem terpusat saat ini, namun tetap siap jika ingin beralih ke ekosistem yang sepenuhnya terdesentralisasi di lain waktu nanti.

Daftar Isi Tutorial

Agar pembahasannya terstruktur dan tidak membingungkan, saya akan membagi tutorial ini menjadi beberapa poin penting yang bisa Anda ikuti:

1. Prasyarat & Hal yang Dibutuhkan

Sebelum kita masuk ke bagian teknis, pastikan Anda sudah menyiapkan beberapa hal berikut agar proses belajarnya lancar:

  • Pemahaman dasar Node.js dan Express.js.
  • Library nostr-tools3 (untuk penanganan kriptografi).
  • Extension Nostr seperti Alby atau Nos2x (Sangat disarankan).
  • Akun Nostr atau pasangan kunci (Public/Private Key).

2. Sekilas Tentang Nostr dan Event serta Proses Penandatanganannya

Saat ini ada protokol baru bernama Nostr, sebuah protokol jejaring sosial yang bersifat terdesentralisasi (Tonton Penjelasan Nostr). Meskipun lahir di ekosistem desentralisasi, mekanisme keamanannya sangat fleksibel untuk diadopsi ke berbagai jenis arsitektur. Salah satu fitur yang sangat berguna adalah NIP-98 (HTTP Auth).

Dengan mekanisme ini, proses autentikasi dilakukan dengan cara menandatangani event khusus secara kriptografi dan mengirimnya melalui Authorization header. Event yang telah ditandatangani kemudian diverifikasi di sisi server. Dengan alur ini, kunci atau kata sandi tidak pernah dikirimkan ke internet, melainkan tetap tersimpan aman di perangkat pengguna.

Nostr secara teknis berkomunikasi melalui WebSocket dengan saling mengirim paket event. Namun, kita dapat mengadaptasi alur NIP-98 ini ke dalam sistem tersentralisasi (menggunakan protokol HTTP tradisional). Ini memberikan keuntungan unik: sistem Anda bisa tetap tersentralisasi secara infrastruktur, namun memiliki keamanan identitas sekelas sistem terdesentralisasi.

Apa itu Event di Nostr?

Nostr adalah paket data terstuktur yang berisi pesan dan metadata yang kemudian ditandatangani dengan kunci privat pengguna. Event itu seperti “record” atau “objek pesan” yang menyatakan: “Aku (pemilik pubkey ini) mengatakan / melakukan X pada waktu Y”, dan dibuktikan dengan tanda tangan kriptografi. Untuk menjaga keteraturan, maka dibuatlah Nostr Improvement Proposal (NIP) untuk berbagai kebutuhan seperti autentikasi, komentar, membuat postingan, dan masih banyak lainnya. Lihat Nostr NIPs

Contoh struktur dasar event Nostr (NIP-01), sebuah event memiliki field utama seperti ini:

json
{
  "id": "…",         // hash SHA-256 dari event yg diserialisasi
  "pubkey": "…",     // kunci publik penulis (identitas Nostr)
  "created_at": 0,   // timestamp (detik sejak UNIX epoch)
  "kind": 1,         // tipe event (1 = note, 0 = metadata, dll)
  "tags": [          // array tag (array of array string)
    ["e", "<event-id-lain>", "..."],
    ["p", "<pubkey-lain>", "..."]
  ],
  "content": "…",    // isi pesan / data utama
  "sig": "…"         // tanda tangan Schnorr atas id
}

Penjelasan singkatnya:

  • pubkey: identitas penulis event (x-only secp256k1, BIP-3404).
  • created_at: kapan event dibuat.
  • kind: jenis event, misalnya:
    • 0: metadata (profil)
    • 1: note (status/post)
    • 7: reaction
    • 27235: HTTP Auth (NIP-98) seperti yang akan kita gunakan sekarang.
  • tags: metadata tambahan dalam bentuk list, misalnya mention user (["p", "<pubkey>"]), reply ke event lain (["e", "<id>"]), URL, dsb. Untuk NIP-98 dipakai ["u", "<url>"], ["method", "GET"], dll.
  • content: isi utama event, bisa teks, JSON (kadang diserialisasi string), dsb.
  • id: hash SHA-256 dari bentuk ter-serialize [0, pubkey, created_at, kind, tags, content].
  • sig: tanda tangan Schnorr (BIP-340) atas id dengan privkey penulis.

Agar paket event tersebut dapat terbukti valid, maka dilakukanlah proses pendandatanganan event yang dibuktikan melalui properti sig sebelum event tersebut dapat dikirimkan ke server. Meskipun kind/jenis event berbeda-beda pada tiap NIP, proses pendandatanganan event Nostr selalu menggunakan algoritma yang sama:

  • Kurva eliptik secp256k1
  • Algoritma signature Schnorr (Standar BIP-340)
  • Hash: SHA-256

3. Kunci dan Identitas Nostr

Nostr menggunakan kunci publik dan private untuk identitas pengguna. Kunci publik adalah identitas pengguna yang akan digunakan sebagai identitas publik, sementara kunci private adalah kunci yang akan digunakan untuk menandatangani event.

Warning

Jangan pernah membagikan kunci private Anda, karena akan merusak keamanan data Anda. Sekali ketahuan, kunci private Anda akan terbuka dan dapat diakses oleh siapa saja.

  • Private Key: Berupa 32 byte random, biasanya ditulis dalam bentuk hex (atau dimulai dengan nsec jika menggunakan bech32). Kunci ini yang akan digunakan untuk menandatangani event.
  • Public Key: Berupa 32 byte hasil dari private key * G, biasanya ditulis dalam bentuk hex (atau dimulai dengan npub jika menggunakan bech32).
Note

G adalah generator point dari secp256k1.

Penandatanganan Event (Konseptual)

Secara konseptual, flow-nya adalah:

  1. Buat event (seperti contoh event NIP-01 di atas) tanpa id dan sig.
  2. Hitung id dari event tersebut dengan menggunakan algoritma SHA-256 (Event diserialisasi ke array JSON lalu di stringifikasi terlebih dahulu).
  3. Hitung sig dari event tersebut dengan menggunakan algoritma Schnorr BIP-340 dan private key. sig = sign(private_key, id)
  4. Tambahkan id and sig ke event tersebut.

Setelah event ditandatangani, maka event tersebut dapat dikirimkan ke server Nostr. Jika dalam kasus kita, event tersebut akan dikirimkan melalui HTTP Auth (Authorization Header).

Verifikasi Event (Di Server)

Di server, verifikasi event tersebut dilakukan dengan cara:

  1. Rekonstruksi array yang ter-serialize dari event tersebut.
  2. Hitung id dari event tersebut dengan menggunakan algoritma SHA-256.
  3. Cek apakah id yang dihitung sama dengan id yang ada di event tersebut.
  4. Lakukan verifikasi signature dengan menggunakan algoritma Schnorr BIP-340 dan public key. schnorr_verify_bip340(public_key, sig, id)
  5. Kemudian lakukan verifikasi lanjutan sesuai dengan spesifikasi NIP masing-masing. Dalam kasus kita, NIP-98.

Jika semua lolos verifikasi, maka event tersebut dapat dianggap valid.

4. Flow Implementasi NIP-98 - HTTP Auth

NIP-98 adalah sebuah event sementara yang digunakan untuk mengautentikasi pengguna di server Nostr. Event ini akan dikirimkan melalui HTTP Auth (Authorization Header) dengan format Nostr <base64(event)>.

Spesifikasi dan Cara Verifikasi

Spesifikasi Event:

  1. kind harus sama dengan 27235
  2. properti content harus kosong
  3. properti tags harus berisi:
    • u: URL yang akan diakses (Misalkan url login)
    • method: HTTP method yang akan digunakan (Biasanya kita menggunakan POST)
  4. id didapatkan dari hasil hash SHA-256 dari array event
  5. sig didapatkan dari hasil signature Schnorr BIP-340 dari private key dan id

Contoh event NIP-98 yang sudah ditandatangani:

json
{
    "kind": 27235,
    "tags": [
        ["u", "https://example.com/login/nostr"],
        ["method", "POST"]
    ],
    "created_at": 1633072800,
    "id": "2ce42dd097abd880aab3cae0c2889791bd44a24ab2f5056781802285243cd39f",
    "sig": "09df22a761c28b130e2e84068de6bd4d10affed5a786ebebae0c2a087241a878063b9f64ab58028eec07623ee47578c1d8969bff871fb2b3164ff18501887680"
}

Client akan mengirimkan event tersebut melalui HTTP Auth (Authorization Header) dengan format Nostr <base64(event)> ke server. Lalu server akan melakukan verifikasi ketat:

  1. Kind harus sama dengan 27235.
  2. created_at harus dalam batas waktu kecil (reasonable time window). Misalnya 60 detik. Ini digunakan untuk memastikan bahwa event tersebut tidak terlalu lama.
  3. u tag harus persis sama dengan absolute URL yang dikirimkan client.
  4. method tag harus sama dengan HTTP method yang digunakan saat request.

Alur Kerja Implementasi

Secara lengkap, berikut adalah gambaran flow dari implementasi NIP-98 - HTTP Auth dalam bentuk sequence diagram:

sequenceDiagram
    participant User as User (Browser + Nostr extension)
    participant Server as Server (HTTP API)
    participant JWTIssuer as JWT Issuer (part of Server)
    participant Sealer as Cookie/Iron Sealer (part of Server)

    Note over User: 1) Sign NIP-98 Event<br/>kind=27235, tags: ["u", url], ["method", HTTP]<br/>created_at=now, sig = schnorr.sign(id)
    
    User->>Server: 2) HTTP request<br/>Authorization: "Nostr <base64(event)>"
    activate Server

    rect rgb(240, 240, 240)
    Note right of Server: 3) Validate auth header and event
    Server->>JWTIssuer: Unpack token (base64→JSON event)
    activate JWTIssuer
    JWTIssuer-->>Server: JSON event
    deactivate JWTIssuer

    Note over Server: Verify Event Internal Checks:<br/>a) signature valid?<br/>b) id valid?<br/>c) kind === 27235<br/>d) created_at within 60s<br/>e) tags (u, method) match
    end

    alt Check fails
        Server-->>User: 401/400 Error Response
    else Success
        Server->>JWTIssuer: 4) Issue JWTs
        activate JWTIssuer
        JWTIssuer-->>Server: Returns tokens
        deactivate JWTIssuer

        Server->>Sealer: 5) Seal tokens
        activate Sealer
        Sealer-->>Server: Returns sealed string
        deactivate Sealer

        Server-->>User: Final Success Response (JSON + Set-Cookie)
    end
    deactivate Server

Penjelasan singkatnya:

  1. Pertama-tama frontend user akan membuat event NIP-98 dengan kind=27235.
  2. Lalu frontend user akan mengirimkan event tersebut melalui HTTP Auth (Authorization Header) dengan format Nostr <base64(event)> ke server.
  3. Server akan melakukan verifikasi event tersebut secara ketat.
  4. Jika event tersebut valid, maka server akan membuat dan memberikan response berupa JWT (JSON Web Token).
  5. Server akan menyimpan JWT tersebut dalam cookie browser user.
  6. User dapat menggunakan JWT tersebut untuk mengakses API yang dilindungi (memerlukan autentikasi).

5. Implementasi Backend (Node.js + Express)

Sekarang kita masuk ke bagian selanjutnya. Di sini saya akan menggunakan Node.js dan Express.js untuk mendemonstrasikan bagaimana sisi backend bekerja. Kita akan buat satu service khusus untuk verifikasi dan satu endpoint untuk login.

Tahap 1 — Membuat Nostr Verification Service

Tujuan: Membuat fungsi reusable untuk memverifikasi validitas event NIP-98.

typescript
// services/nostrAuthService.ts
import { verifySignature, getEventHash } from 'nostr-tools';
import crypto from 'crypto';

export async function verifyNip98Event(event: any, options: { url: string, method: string, body?: any }) {
    // 1. Verifikasi tipe event (kind 27235)
    if (event.kind !== 27235) return false;

    // 2. Cek window waktu (toleransi 60 detik)
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - event.created_at) > 60) return false;

    // 3. Cocokkan URL dan Method
    const uTag = event.tags.find((t: any[]) => t[0] === 'u')?.[1];
    const methodTag = event.tags.find((t: any[]) => t[0] === 'method')?.[1];

    if (uTag !== options.url || methodTag !== options.method) return false;

    // 4. Verifikasi Payload (jika ada body)
    if (options.body && Object.keys(options.body).length > 0) {
        const payloadTag = event.tags.find((t: any[]) => t[0] === 'payload')?.[1];
        const bodyHash = crypto.createHash('sha256').update(JSON.stringify(options.body)).digest('hex');
        if (payloadTag !== bodyHash) return false;
    }

    // 5. Verifikasi Signature secara Kriptografi
    const id = getEventHash(event);
    if (id !== event.id) return false;

    return verifySignature(event);
}

Tahap 2 — Membuat Route Login

Tujuan: Menerima request dari client, memverifikasi event, dan menerbitkan token sesi.

typescript
// routes/auth.ts
import express from 'express';
import jwt from 'jsonwebtoken';
import Iron from '@hapi/iron';
import { verifyNip98Event } from '../services/nostrAuthService';

const router = express.Router();

router.post('/login/nostr', async (req, res) => {
    const authHeader = req.headers.authorization;
    if (!authHeader?.startsWith('Nostr ')) {
        return res.status(401).json({ error: 'Auth header tidak ditemukan' });
    }

    try {
        const base64Event = authHeader.replace('Nostr ', '');
        const event = JSON.parse(Buffer.from(base64Event, 'base64').toString('utf-8'));

        // Ambil URL lengkap untuk verifikasi tag 'u'
        const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
        const isValid = await verifyNip98Event(event, { 
            url: fullUrl, 
            method: 'POST', 
            body: req.body 
        });

        if (!isValid) return res.status(401).json({ error: 'Nostr event tidak valid' });

        // Step 4 & 5: Issue JWT & Seal Session
        const payload = { pubkey: event.pubkey };
        const accessToken = jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: '15m' });
        const refreshToken = jwt.sign(payload, process.env.REFRESH_SECRET!, { expiresIn: '7d' });

        // Menggunakan Iron untuk enkripsi cookie tingkat tinggi
        const sealed = await Iron.seal({ accessToken, refreshToken }, process.env.IRON_PASSWORD!, Iron.defaults);

        res.cookie('auth_session', sealed, {
            httpOnly: true,
            secure: true,
            sameSite: 'strict',
            maxAge: 7 * 24 * 60 * 60 * 1000
        });

        return res.json({ success: true, user: event.pubkey });
    } catch (err) {
        return res.status(500).json({ error: 'Internal Server Error' });
    }
});

6. Implementasi Frontend

Nah, setelah bagian backend selesai, sekarang kita pindah ke sisi client (frontend). Di sini kita akan memanfaatkan interface window.nostr yang biasanya disediakan oleh extension seperti Alby agar kunci private user tetap aman.

Form Login Sederhana

html
<div id="login-form">
    <h2>Login dengan Nostr</h2>
    <p>Gunakan extension atau akun Anda dengan aman.</p>
    
    <button id="btn-extension" onclick="loginWithExtension()">
        Login via Extension (NIP-07)
    </button>
    
    <div style="margin: 20px 0;">Atau</div>
    
    <input type="password" id="nsec-input" placeholder="Masukkan nsec Anda (Private Key)">
    <button onclick="loginWithNsec()">Login via nsec</button>
</div>

Logika Penandatanganan (Signing Request)

typescript
async function loginWithExtension() {
    if (!window.nostr) return alert('Silahkan pasang nostr extension!');

    const loginUrl = 'https://api.anda.com/login/nostr';
    
    // 1. Buat Payload Event
    const eventTemplate = {
        kind: 27235,
        created_at: Math.floor(Date.now() / 1000),
        tags: [
            ['u', loginUrl],
            ['method', 'POST']
        ],
        content: ''
    };

    // 2. Minta Extension untuk menandatangani
    const signedEvent = await window.nostr.signEvent(eventTemplate);

    // 3. Kirim via header Authorization
    const base64Event = btoa(JSON.stringify(signedEvent));
    const res = await fetch(loginUrl, {
        method: 'POST',
        headers: {
            'Authorization': `Nostr ${base64Event}`,
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ /* data tambahan */ })
    });

    if (res.ok) alert('Login Berhasil!');
}

7. Kelebihan & Kekurangan Penggunaan Nostr NIP-98

Setiap teknologi memiliki pertimbangannya sendiri:

Kelebihan:

  • Zero-Trust for Passwords: Server tidak pernah melihat private key atau password Anda.
  • Interoperabilitas: Private key Anda bisa digunakan di berbagai platform Nostr tanpa perlu membuat ulang Private key yang baru.
  • Keamanan Kriptografi: Menggunakan standar Schnorr Signature yang lebih modern dibanding ECDSA.

Kekurangan:

  • Key Management: Jika user kehilangan private key, tidak ada fitur “Forgot Password”, namun karena kita hanya mengadaptasi NIP-98 ke dalam sistem yang tersentralisasi, maka user tetap bisa menggunakan private key yang baru jika kita sebagai pengembang mengizinkan pengguna untuk mengganti private key.
  • Adopsi: Memerlukan edukasi tambahan bagi pengguna awam tentang konsep kunci publik/privat.

8. Troubleshooting & FAQ

Gejala: Error “NIP-98 event expired”

  • Penyebab: Perbedaan waktu (clock skew) antara perangkat client dan server.
  • Solusi: Pastikan server sinkron dengan NTP, dan berikan batas waktu yang lebih longgar jika diperlukan.

FAQ: Apakah aman menyimpan nsec di localStorage? Sangat tidak disarankan dalam bentuk plain text. Jika terpaksa, enkripsilah nsec tersebut dengan password lain dan hanya simpan hasil enkripsinya.


Sekian artikel kali ini, semoga dapat membantu Anda dalam implementasi Nostr NIP-98 di aplikasi Anda. Dengan kombinasi ini, kita bisa memberikan standar keamanan yang jauh lebih tinggi bagi pengguna! 😊

Footnotes

  1. JWT RFC 7519 - Official Document

  2. NIP-98 HTTP Auth - GitHub Spec

  3. nostr-tools Library - GitHub Repository

  4. BIP-340 Schnorr Signatures - Bitcoin Specification

Discovery / Related