跳至主要內容
進階

NIP-42 中繼器認證

深入了解 Nostr 中繼器的客戶端認證機制,使用簽名事件驗證用戶身份。

8 分鐘

什麼是 NIP-42?

NIP-42 定義了客戶端向中繼器證明身份的認證機制。中繼器可以要求客戶端 簽署一個包含挑戰字串的事件來驗證其 Nostr 身份。這讓中繼器可以實現 存取控制、付費訂閱、白名單等功能。

使用場景: 付費中繼器、私人中繼器、需要身份驗證的進階功能、 防止濫用的存取控制等。

認證流程

客戶端                                      中繼器
   │                                           │
   │  1. 建立 WebSocket 連線                   │
   │  ─────────────────────────────────────>   │
   │                                           │
   │  2. 發送認證挑戰                           │
   │  <─────────────────────────────────────   │
   │     ["AUTH", "<challenge-string>"]        │
   │                                           │
   │  3. 簽署認證事件                           │
   │     kind: 22242                           │
   │     tags: [["relay", "wss://..."],        │
   │            ["challenge", "<challenge>"]]  │
   │                                           │
   │  4. 發送認證事件                           │
   │  ─────────────────────────────────────>   │
   │     ["AUTH", <signed-event>]              │
   │                                           │
   │  5. 驗證並回應                             │
   │  <─────────────────────────────────────   │
   │     ["OK", "<event-id>", true, ""]        │
   │                                           │

訊息格式

AUTH 挑戰(中繼器發送)

// 中繼器在需要認證時發送
["AUTH", "<challenge-string>"]

// challenge-string 是中繼器產生的隨機字串
// 用於防止重放攻擊
// 範例
["AUTH", "a1b2c3d4e5f6g7h8i9j0"]

認證事件(客戶端發送)

// 認證事件 (kind 22242)
{
  "kind": 22242,
  "content": "",
  "tags": [
    ["relay", "wss://relay.example.com/"],
    ["challenge", "a1b2c3d4e5f6g7h8i9j0"]
  ],
  "pubkey": "<客戶端公鑰>",
  "created_at": 1704067200,
  "id": "...",
  "sig": "..."
}

// 發送格式
["AUTH", <signed-event-json>]

認證結果

// 成功
["OK", "<event-id>", true, ""]

// 失敗
["OK", "<event-id>", false, "auth-required: 需要認證"]
["OK", "<event-id>", false, "restricted: 沒有存取權限"]

// 常見的 CLOSED 訊息(請求被拒絕)
["CLOSED", "<subscription-id>", "auth-required: 需要認證才能訂閱"]
["CLOSED", "<subscription-id>", "restricted: 此內容需要付費訂閱"]

程式碼範例

客戶端認證處理

import { finalizeEvent } from 'nostr-tools'

class AuthenticatedRelay {
  constructor(url, signer = window.nostr) {
    this.url = url
    this.signer = signer
    this.ws = null
    this.authenticated = false
  }

  connect() {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(this.url)

      this.ws.onopen = () => {
        console.log('已連線到', this.url)
      }

      this.ws.onmessage = async (e) => {
        const message = JSON.parse(e.data)
        await this.handleMessage(message, resolve)
      }

      this.ws.onerror = reject
    })
  }

  async handleMessage(message, onReady) {
    const [type, ...rest] = message

    switch (type) {
      case 'AUTH':
        // 收到認證挑戰
        const challenge = rest[0]
        await this.authenticate(challenge)
        break

      case 'OK':
        const [eventId, success, msg] = rest
        if (success && !this.authenticated) {
          this.authenticated = true
          console.log('認證成功')
          onReady?.(this)
        } else if (!success) {
          console.error('操作失敗:', msg)
        }
        break

      case 'CLOSED':
        const [subId, reason] = rest
        if (reason.startsWith('auth-required:')) {
          console.log('需要認證,等待 AUTH 挑戰...')
        }
        break
    }
  }

  async authenticate(challenge) {
    console.log('收到認證挑戰:', challenge)

    // 建立認證事件
    const authEvent = {
      kind: 22242,
      content: '',
      created_at: Math.floor(Date.now() / 1000),
      tags: [
        ['relay', this.url],
        ['challenge', challenge]
      ]
    }

    // 簽名
    const signedEvent = await this.signer.signEvent(authEvent)

    // 發送認證
    this.ws.send(JSON.stringify(['AUTH', signedEvent]))
    console.log('已發送認證事件')
  }

  // 其他方法...
  publish(event) {
    this.ws.send(JSON.stringify(['EVENT', event]))
  }

  subscribe(filters, handlers) {
    const subId = Math.random().toString(36).slice(2)
    this.ws.send(JSON.stringify(['REQ', subId, ...filters]))
    return subId
  }
}

// 使用
const relay = new AuthenticatedRelay('wss://paid-relay.example.com')
await relay.connect()
// 自動處理認證挑戰

中繼器端驗證

import { verifyEvent } from 'nostr-tools'

class RelayAuthenticator {
  constructor(relayUrl) {
    this.relayUrl = relayUrl
    this.challenges = new Map()  // challenge -> { created, clientId }
    this.authenticated = new Map()  // clientId -> pubkey
  }

  // 產生挑戰
  createChallenge(clientId) {
    const challenge = crypto.randomUUID()
    this.challenges.set(challenge, {
      created: Date.now(),
      clientId
    })

    // 60 秒後過期
    setTimeout(() => {
      this.challenges.delete(challenge)
    }, 60000)

    return challenge
  }

  // 驗證認證事件
  validateAuth(clientId, event) {
    // 1. 驗證事件類型
    if (event.kind !== 22242) {
      return { valid: false, error: '錯誤的事件類型' }
    }

    // 2. 驗證簽名
    if (!verifyEvent(event)) {
      return { valid: false, error: '簽名驗證失敗' }
    }

    // 3. 驗證時間戳(10 分鐘內)
    const now = Math.floor(Date.now() / 1000)
    if (Math.abs(now - event.created_at) > 600) {
      return { valid: false, error: '事件已過期' }
    }

    // 4. 驗證 relay 標籤
    const relayTag = event.tags.find(t => t[0] === 'relay')
    if (!relayTag || relayTag[1] !== this.relayUrl) {
      return { valid: false, error: 'relay 標籤不匹配' }
    }

    // 5. 驗證 challenge 標籤
    const challengeTag = event.tags.find(t => t[0] === 'challenge')
    if (!challengeTag) {
      return { valid: false, error: '缺少 challenge 標籤' }
    }

    const challengeData = this.challenges.get(challengeTag[1])
    if (!challengeData) {
      return { valid: false, error: '無效的 challenge' }
    }

    if (challengeData.clientId !== clientId) {
      return { valid: false, error: 'challenge 不屬於此連線' }
    }

    // 6. 驗證通過
    this.challenges.delete(challengeTag[1])
    this.authenticated.set(clientId, event.pubkey)

    return { valid: true, pubkey: event.pubkey }
  }

  // 檢查是否已認證
  isAuthenticated(clientId) {
    return this.authenticated.has(clientId)
  }

  // 取得已認證的公鑰
  getPubkey(clientId) {
    return this.authenticated.get(clientId)
  }
}

// 使用範例(WebSocket 伺服器)
wss.on('connection', (ws, req) => {
  const clientId = req.headers['sec-websocket-key']

  // 發送認證挑戰
  const challenge = authenticator.createChallenge(clientId)
  ws.send(JSON.stringify(['AUTH', challenge]))

  ws.on('message', (data) => {
    const message = JSON.parse(data)
    const [type, ...rest] = message

    if (type === 'AUTH') {
      const event = rest[0]
      const result = authenticator.validateAuth(clientId, event)

      if (result.valid) {
        ws.send(JSON.stringify(['OK', event.id, true, '']))
      } else {
        ws.send(JSON.stringify(['OK', event.id, false, result.error]))
      }
    }
  })
})

何時需要認證

連線時

  • • 私人中繼器
  • • 付費中繼器
  • • 企業內部中繼器

特定操作時

  • • 發布事件
  • • 訂閱私人內容
  • • 存取進階功能

與 NIP-11 整合

中繼器可以在 NIP-11 資訊中宣告認證需求:

// NIP-11 relay information
{
  "name": "私人中繼器",
  "description": "僅限成員使用",
  "supported_nips": [1, 11, 42],
  "limitation": {
    "auth_required": true,           // 需要認證
    "payment_required": false,
    "restricted_writes": true        // 只有認證用戶可以發布
  }
}

// 客戶端可以先查詢 NIP-11 資訊
// 了解中繼器是否需要認證
async function checkAuthRequired(relayUrl) {
  const httpUrl = relayUrl
    .replace('wss://', 'https://')
    .replace('ws://', 'http://')

  const response = await fetch(httpUrl, {
    headers: { 'Accept': 'application/nostr+json' }
  })

  const info = await response.json()
  return info.limitation?.auth_required === true
}

安全考量

安全特性

  • • 挑戰字串防止重放攻擊
  • • relay 標籤防止跨站認證
  • • 時間戳限制事件有效期
  • • 簽名驗證確保身份真實

實作建議

  • • 使用足夠長的隨機挑戰
  • • 設定挑戰過期時間
  • • 每個連線使用唯一挑戰
  • • 記錄認證失敗嘗試

常見錯誤處理

// 處理認證相關錯誤
function handleAuthError(message) {
  const [type, id, success, reason] = message

  if (!success && reason) {
    if (reason.startsWith('auth-required:')) {
      // 需要認證,等待 AUTH 挑戰
      console.log('等待認證挑戰...')
      return 'wait_auth'
    }

    if (reason.startsWith('restricted:')) {
      // 沒有權限
      console.error('存取被拒絕:', reason)
      return 'no_permission'
    }

    if (reason.includes('invalid') || reason.includes('expired')) {
      // 認證失敗
      console.error('認證失敗:', reason)
      return 'auth_failed'
    }
  }

  return success ? 'success' : 'unknown_error'
}

// 自動重試認證
async function connectWithAuth(relayUrl, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const relay = new AuthenticatedRelay(relayUrl)
      await relay.connect()

      if (relay.authenticated) {
        return relay
      }
    } catch (e) {
      console.error(`連線嘗試 ${i + 1} 失敗:`, e)
      await new Promise(r => setTimeout(r, 1000 * (i + 1)))
    }
  }

  throw new Error('無法連線到中繼器')
}

需要認證的中繼器

付費中繼器

需要訂閱才能使用

relay.nostr.wine

私人中繼器

僅限邀請成員

企業/社群中繼器

垃圾訊息防護

認證後才能發布

防濫用中繼器

提示: 大多數 Nostr 客戶端會自動處理 NIP-42 認證流程。 用戶通常只需要在連接需要認證的中繼器時確認簽名請求。

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