跳至主要內容

NIP-39: 外部身份驗證

在 Nostr 個人資料中連結並驗證外部平台身份

概述

NIP-39 定義了一種在 Nostr 個人資料(kind 0)中聲明並驗證外部平台身份的方法。 用戶可以將 GitHub、Twitter、Mastodon、Telegram 等帳號連結到 Nostr 身份, 並提供可驗證的證明讓其他人確認這些帳號確實屬於同一人。

標籤格式

NIP-39 在 kind 0 事件中引入了 i 標籤,每個標籤包含兩個參數:

["i", "平台:身份", "證明"]
參數 說明 範例
平台:身份 平台名稱與用戶名以 : 連接 github:satoshi
證明 指向所有權驗證的字串或物件 Gist ID、Tweet ID 等

命名規範:平台名稱只能包含 a-z0-9._-/ 字元, 不能包含冒號。身份應該正規化為小寫。

個人資料範例

{
  "kind": 0,
  "content": "{\"name\":\"satoshi\",\"about\":\"Bitcoin creator\",\"picture\":\"...\"}",
  "tags": [
    ["i", "github:satoshi", "abc123def456"],
    ["i", "twitter:sataboroshi", "1234567890123456789"],
    ["i", "mastodon:bitcoinhackers.org/@satoshi", "109876543210"],
    ["i", "telegram:123456789", "bitcoin_channel/42"]
  ]
}

支援平台

GitHub

身份格式 GitHub 用戶名
證明格式 Gist ID
驗證位置 https://gist.github.com/<username>/<gist-id>

Gist 內容必須包含驗證 Nostr 公鑰所有權的文字:

Verifying my Nostr public key: npub1xxx...

This gist proves I control both this GitHub account and the Nostr key above.

標籤範例:

["i", "github:satoshi", "abc123def456789"]

Twitter / X

身份格式 Twitter 用戶名
證明格式 Tweet ID
驗證位置 https://twitter.com/<username>/status/<tweet-id>

推文必須包含 npub 編碼的公鑰:

Verifying my Nostr identity: npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq...

標籤範例:

["i", "twitter:jack", "1234567890123456789"]

Mastodon

身份格式 <instance>/@<username>
證明格式 貼文 ID
驗證位置 https://<instance>/@<username>/<post-id>

標籤範例:

["i", "mastodon:bitcoinhackers.org/@satoshi", "109876543210987654"]

Telegram

身份格式 Telegram 用戶 ID(數字)
證明格式 <channel-or-group>/<message-id>
驗證位置 https://t.me/<channel-or-group>/<message-id>

證明必須是公開頻道或群組中的訊息,該用戶發布的訊息包含其 npub:

["i", "telegram:123456789", "bitcoin_channel/42"]

驗證流程

  1. 用戶在外部平台發布證明:在 GitHub Gist、Twitter 推文等平台發布包含 npub 的驗證訊息
  2. 用戶更新 Nostr 個人資料:在 kind 0 事件中添加 i 標籤
  3. 客戶端抓取證明:根據標籤中的平台和證明 ID 構建 URL
  4. 客戶端驗證:檢查證明內容是否包含正確的 npub
  5. 顯示驗證狀態:向其他用戶展示已驗證的外部身份

TypeScript 實作

建立帶有外部身份的個人資料

import { finalizeEvent, generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';

interface ExternalIdentity {
  platform: 'github' | 'twitter' | 'mastodon' | 'telegram' | string;
  identity: string;
  proof: string;
}

interface ProfileWithIdentities {
  name?: string;
  about?: string;
  picture?: string;
  nip05?: string;
  lud16?: string;
  identities: ExternalIdentity[];
}

function createProfileEvent(
  profile: ProfileWithIdentities,
  secretKey: Uint8Array
) {
  const { identities, ...metadata } = profile;

  // 建立 i 標籤
  const iTags = identities.map((id) => [
    'i',
    `${id.platform}:${id.identity.toLowerCase()}`,
    id.proof,
  ]);

  const event = {
    kind: 0,
    content: JSON.stringify(metadata),
    tags: iTags,
    created_at: Math.floor(Date.now() / 1000),
  };

  return finalizeEvent(event, secretKey);
}

// 使用範例
const sk = generateSecretKey();
const pk = getPublicKey(sk);
const npub = nip19.npubEncode(pk);

console.log('在外部平台發布此驗證訊息:');
console.log(`Verifying my Nostr identity: ${npub}`);

const profileEvent = createProfileEvent(
  {
    name: 'Satoshi',
    about: 'Building the future',
    picture: 'https://example.com/avatar.jpg',
    nip05: '[email protected]',
    identities: [
      {
        platform: 'github',
        identity: 'satoshi',
        proof: 'abc123def456',
      },
      {
        platform: 'twitter',
        identity: 'satoshi',
        proof: '1234567890123456789',
      },
    ],
  },
  sk
);

console.log(profileEvent);

解析外部身份

interface ParsedIdentity {
  platform: string;
  identity: string;
  proof: string;
  verificationUrl: string;
}

function parseExternalIdentities(event: any): ParsedIdentity[] {
  if (event.kind !== 0) {
    return [];
  }

  const iTags = event.tags.filter((t: string[]) => t[0] === 'i');

  return iTags.map((tag: string[]) => {
    const [platform, identity] = tag[1].split(':');
    const proof = tag[2];

    return {
      platform,
      identity,
      proof,
      verificationUrl: buildVerificationUrl(platform, identity, proof),
    };
  });
}

function buildVerificationUrl(
  platform: string,
  identity: string,
  proof: string
): string {
  switch (platform) {
    case 'github':
      return `https://gist.github.com/${identity}/${proof}`;

    case 'twitter':
      return `https://twitter.com/${identity}/status/${proof}`;

    case 'mastodon':
      // identity 格式為 instance/@username
      return `https://${identity}/${proof}`;

    case 'telegram':
      // proof 格式為 channel/message-id
      return `https://t.me/${proof}`;

    default:
      return '';
  }
}

// 使用範例
const identities = parseExternalIdentities(profileEvent);
identities.forEach((id) => {
  console.log(`${id.platform}: ${id.identity}`);
  console.log(`驗證連結: ${id.verificationUrl}`);
});

驗證外部身份

import { nip19 } from 'nostr-tools';

interface VerificationResult {
  platform: string;
  identity: string;
  verified: boolean;
  error?: string;
}

async function verifyIdentity(
  identity: ParsedIdentity,
  expectedPubkey: string
): Promise<VerificationResult> {
  const expectedNpub = nip19.npubEncode(expectedPubkey);

  try {
    // 抓取驗證內容
    const response = await fetch(identity.verificationUrl);

    if (!response.ok) {
      return {
        platform: identity.platform,
        identity: identity.identity,
        verified: false,
        error: `無法取得驗證內容: ${response.status}`,
      };
    }

    const content = await response.text();

    // 檢查內容是否包含 npub
    const verified = content.includes(expectedNpub);

    return {
      platform: identity.platform,
      identity: identity.identity,
      verified,
      error: verified ? undefined : '驗證內容中未找到正確的 npub',
    };
  } catch (error) {
    return {
      platform: identity.platform,
      identity: identity.identity,
      verified: false,
      error: `驗證失敗: ${error}`,
    };
  }
}

// 批次驗證所有身份
async function verifyAllIdentities(
  event: any
): Promise<VerificationResult[]> {
  const identities = parseExternalIdentities(event);

  const results = await Promise.all(
    identities.map((id) => verifyIdentity(id, event.pubkey))
  );

  return results;
}

客戶端 UI 元件範例

// React 元件範例
interface IdentityBadgeProps {
  identity: ParsedIdentity;
  verified: boolean;
}

function IdentityBadge({ identity, verified }: IdentityBadgeProps) {
  const platformIcons: Record<string, string> = {
    github: '🐙',
    twitter: '🐦',
    mastodon: '🐘',
    telegram: '✈️',
  };

  const icon = platformIcons[identity.platform] || '🔗';

  return (
    <a
      href={identity.verificationUrl}
      target="_blank"
      rel="noopener noreferrer"
      className={`identity-badge ${verified ? 'verified' : 'unverified'}`}
    >
      <span className="icon">{icon}</span>
      <span className="platform">{identity.platform}</span>
      <span className="identity">{identity.identity}</span>
      {verified && <span className="check">✓</span>}
    </a>
  );
}

自訂平台

NIP-39 允許添加任何平台,只要遵循命名規範。以下是一些可能的自訂平台範例:

{
  "tags": [
    ["i", "youtube:@channelname", "video-id"],
    ["i", "linkedin:username", "post-id"],
    ["i", "reddit:u/username", "post-id"],
    ["i", "keybase:username", "proof-id"],
    ["i", "dns:example.com", "TXT-record-hash"]
  ]
}

安全考量

  • 證明可能失效:用戶可能刪除 Gist 或推文,客戶端應處理驗證失敗的情況
  • 平台 API 限制:直接抓取可能受到速率限制,考慮使用快取
  • 偽造風險:惡意用戶可能聲稱他人的帳號,務必驗證證明內容
  • 隱私考量:連結身份會降低匿名性,用戶應謹慎選擇要公開的帳號

使用場景

身份驗證

  • 證明你是某個知名 GitHub 開發者
  • 連結你的 Twitter 帳號增加可信度
  • 從其他社群平台遷移時保持身份連續性

社群信任

  • 讓追隨者確認你是真正的帳號
  • 在 Nostr 上建立與現有網路身份的連結
  • 減少冒充攻擊的風險

跨平台發現

  • 讓其他平台的追隨者找到你的 Nostr 帳號
  • 建立統一的線上身份

最佳實踐

  • 保持證明有效:不要刪除用於驗證的 Gist 或推文
  • 正規化身份:使用小寫字母避免重複聲明
  • 定期驗證:客戶端應該定期重新驗證外部身份
  • 顯示驗證狀態:清楚區分已驗證和未驗證的身份
  • 處理失敗:優雅地處理驗證失敗的情況
  • NIP-01:基本協議 - kind 0 個人資料事件
  • NIP-05:域名驗證 - 另一種身份驗證方式
  • NIP-19:bech32 編碼 - npub 格式

參考資源

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