進階
NIP-98 HTTP 認證
深入了解 Nostr 的 HTTP 認證標準,使用簽名事件作為 Bearer token 進行 API 認證。
8 分鐘
什麼是 NIP-98?
NIP-98 定義了使用 Nostr 事件進行 HTTP API 認證的標準。客戶端簽署一個特殊的 kind 27235 事件,將其 Base64 編碼後作為 Bearer token 發送。這讓伺服器可以 驗證請求來自特定的 Nostr 身份,無需傳統的帳號密碼。
主要用途: NIP-98 被廣泛用於 NIP-96 檔案上傳、付費中繼器認證、 API 服務存取等需要身份驗證的場景。
認證事件結構
{
"kind": 27235,
"created_at": 1704067200,
"tags": [
["u", "https://api.example.com/upload"], // 目標 URL(必須)
["method", "POST"], // HTTP 方法(必須)
["payload", "sha256-hash-of-request-body"] // 請求體雜湊(可選)
],
"content": "",
"pubkey": "<你的公鑰>",
"id": "...",
"sig": "..."
} 標籤說明
| 標籤 | 必須 | 說明 |
|---|---|---|
| u | 是 | 請求的完整 URL(包含路徑和查詢參數) |
| method | 是 | HTTP 方法(GET、POST、PUT、DELETE 等) |
| payload | 可選 | 請求體的 SHA-256 雜湊(十六進位) |
認證流程
客戶端 伺服器
│ │
│ 1. 建立 kind 27235 事件 │
│ - 設定 u 標籤為目標 URL │
│ - 設定 method 標籤 │
│ - 可選:計算 payload 雜湊 │
│ │
│ 2. 簽署事件 │
│ │
│ 3. Base64 編碼事件 JSON │
│ │
│ 4. 發送 HTTP 請求 │
│ Authorization: Nostr <base64> ───> │
│ │
│ 5. 解碼並驗證事件 │
│ - 驗證簽名 │
│ - 檢查 URL 匹配 │
│ - 檢查時間戳 │
│ - 檢查 payload │
│ │
│ <─────────────────────── 6. 回應結果 │
│ │ 程式碼範例
建立認證標頭
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
async function createNip98AuthHeader(url, method, body = null) {
const event = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method.toUpperCase()]
],
content: ''
}
// 如果有請求體,計算 SHA-256 雜湊
if (body) {
const hash = await sha256(body)
event.tags.push(['payload', hash])
}
// 使用瀏覽器擴充簽名(NIP-07)
const signedEvent = await window.nostr.signEvent(event)
// Base64 編碼
const encoded = btoa(JSON.stringify(signedEvent))
return `Nostr ${encoded}`
}
// SHA-256 雜湊函數
async function sha256(data) {
let buffer
if (typeof data === 'string') {
buffer = new TextEncoder().encode(data)
} else if (data instanceof FormData) {
// FormData 需要序列化
const text = await new Response(data).text()
buffer = new TextEncoder().encode(text)
} else {
buffer = data
}
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
} 發送認證請求
// GET 請求
async function authenticatedGet(url) {
const authHeader = await createNip98AuthHeader(url, 'GET')
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': authHeader
}
})
return response.json()
}
// POST 請求(JSON body)
async function authenticatedPost(url, data) {
const body = JSON.stringify(data)
const authHeader = await createNip98AuthHeader(url, 'POST', body)
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/json'
},
body: body
})
return response.json()
}
// POST 請求(FormData,用於檔案上傳)
async function authenticatedUpload(url, formData) {
// 注意:對於 multipart/form-data,payload 雜湊計算較複雜
// 部分伺服器可能不要求 payload 標籤
const authHeader = await createNip98AuthHeader(url, 'POST')
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': authHeader
// 不要手動設定 Content-Type,讓瀏覽器自動處理
},
body: formData
})
return response.json()
}
// 使用範例
const userData = await authenticatedGet('https://api.example.com/me')
console.log('用戶資料:', userData) 伺服器端驗證
import { verifyEvent } from 'nostr-tools'
function validateNip98Auth(authHeader, expectedUrl, expectedMethod, bodyHash = null) {
// 1. 解析標頭
if (!authHeader?.startsWith('Nostr ')) {
return { valid: false, error: '無效的 Authorization 標頭格式' }
}
const base64Event = authHeader.slice(6) // 移除 "Nostr " 前綴
// 2. 解碼事件
let event
try {
event = JSON.parse(atob(base64Event))
} catch (e) {
return { valid: false, error: '無效的 Base64 編碼' }
}
// 3. 驗證事件類型
if (event.kind !== 27235) {
return { valid: false, error: '錯誤的事件類型' }
}
// 4. 驗證簽名
if (!verifyEvent(event)) {
return { valid: false, error: '簽名驗證失敗' }
}
// 5. 驗證時間戳(60 秒內)
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - event.created_at) > 60) {
return { valid: false, error: '事件已過期' }
}
// 6. 驗證 URL
const urlTag = event.tags.find(t => t[0] === 'u')
if (!urlTag || urlTag[1] !== expectedUrl) {
return { valid: false, error: 'URL 不匹配' }
}
// 7. 驗證方法
const methodTag = event.tags.find(t => t[0] === 'method')
if (!methodTag || methodTag[1].toUpperCase() !== expectedMethod.toUpperCase()) {
return { valid: false, error: 'HTTP 方法不匹配' }
}
// 8. 驗證 payload(如果提供)
if (bodyHash) {
const payloadTag = event.tags.find(t => t[0] === 'payload')
if (payloadTag && payloadTag[1] !== bodyHash) {
return { valid: false, error: 'Payload 雜湊不匹配' }
}
}
return {
valid: true,
pubkey: event.pubkey,
event: event
}
}
// Express.js 中間件範例
function nip98Middleware(req, res, next) {
const authHeader = req.headers.authorization
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
const result = validateNip98Auth(
authHeader,
fullUrl,
req.method
)
if (!result.valid) {
return res.status(401).json({ error: result.error })
}
req.nostrPubkey = result.pubkey
next()
} 安全考量
安全特性
- • 簽名確保身份真實性
- • URL 綁定防止重放到其他端點
- • 時間戳限制防止重放攻擊
- • Payload 雜湊防止篡改
注意事項
- • 時間戳視窗不應過長(建議 60 秒)
- • URL 必須完全匹配(包含查詢參數)
- • 應該使用 HTTPS 防止中間人攻擊
- • 考慮實作速率限制
時間戳驗證
時間戳驗證是防止重放攻擊的關鍵:
// 推薦的時間視窗配置
const TIME_WINDOW_SECONDS = 60 // 60 秒
function isTimestampValid(eventCreatedAt) {
const now = Math.floor(Date.now() / 1000)
const diff = Math.abs(now - eventCreatedAt)
if (diff > TIME_WINDOW_SECONDS) {
return false
}
return true
}
// 為什麼是 60 秒?
// - 太短:時鐘不同步會導致失敗
// - 太長:重放攻擊視窗增大
// - 60 秒是合理的平衡點
// 進階:結合 nonce 防止精確重放
// 伺服器可以儲存最近看到的事件 ID
// 拒絕重複的事件 ID 與其他認證方式比較
| 特性 | NIP-98 | JWT | API Key |
|---|---|---|---|
| 身份來源 | Nostr 密鑰對 | 伺服器簽發 | 伺服器生成 |
| 去中心化 | 是 | 否 | 否 |
| 重放防護 | 時間戳 + URL | 過期時間 | 無 |
| 跨服務 | 同一身份 | 需重新登入 | 各服務獨立 |
常見使用場景
NIP-96 檔案上傳
驗證上傳者身份
付費中繼器
訂閱驗證
API 服務
用戶認證
內容管理
作者權限驗證
Webhook
來源驗證
閃電網路服務
LNURL-auth 替代
完整範例:認證類別
class Nip98Client {
constructor(signer = window.nostr) {
this.signer = signer
}
async createAuthEvent(url, method, body = null) {
const event = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method.toUpperCase()]
],
content: ''
}
if (body) {
const hash = await this.sha256(body)
event.tags.push(['payload', hash])
}
return await this.signer.signEvent(event)
}
async sha256(data) {
const buffer = typeof data === 'string'
? new TextEncoder().encode(data)
: data
const hash = await crypto.subtle.digest('SHA-256', buffer)
return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
async fetch(url, options = {}) {
const method = options.method || 'GET'
const body = options.body
const authEvent = await this.createAuthEvent(url, method, body)
const authHeader = `Nostr ${btoa(JSON.stringify(authEvent))}`
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': authHeader
}
})
}
// 便捷方法
get(url) {
return this.fetch(url)
}
post(url, data) {
return this.fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
}
delete(url) {
return this.fetch(url, { method: 'DELETE' })
}
}
// 使用
const client = new Nip98Client()
const response = await client.get('https://api.example.com/profile')
const data = await response.json() 提示: NIP-98 認證讓你可以用同一個 Nostr 身份登入多個服務, 無需為每個服務建立帳號。這是 Nostr 生態系統「一個身份,處處使用」理念的體現。
已複製連結