跳至主要內容
進階

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 生態系統「一個身份,處處使用」理念的體現。

已複製連結
已複製到剪貼簿