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-z、0-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"] 驗證流程
- 用戶在外部平台發布證明:在 GitHub Gist、Twitter 推文等平台發布包含 npub 的驗證訊息
- 用戶更新 Nostr 個人資料:在 kind 0 事件中添加
i標籤 - 客戶端抓取證明:根據標籤中的平台和證明 ID 構建 URL
- 客戶端驗證:檢查證明內容是否包含正確的 npub
- 顯示驗證狀態:向其他用戶展示已驗證的外部身份
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 或推文
- 正規化身份:使用小寫字母避免重複聲明
- 定期驗證:客戶端應該定期重新驗證外部身份
- 顯示驗證狀態:清楚區分已驗證和未驗證的身份
- 處理失敗:優雅地處理驗證失敗的情況
相關 NIPs
參考資源
已複製連結