NIP-17 私密訊息
Nostr 私密直接訊息標準,使用 NIP-44 加密和 NIP-59 禮物包裝實現完整的中繼資料保護。
概述
NIP-17 定義了 Nostr 的私密直接訊息標準,取代了舊的 NIP-04。 它結合 NIP-44 加密和 NIP-59 禮物包裝機制,不僅加密訊息內容, 還隱藏發送者身份、接收者身份和發送時間等中繼資料。
推薦使用
NIP-17 是 NIP-04 的安全替代方案。新的客戶端應該優先實現 NIP-17, 它提供更強的隱私保護和更現代的加密機制。
事件類型
NIP-17 使用以下事件類型:
| Kind | 名稱 | 說明 |
|---|---|---|
| 14 | 聊天訊息 | 文字私訊內容(未簽名) |
| 15 | 檔案訊息 | 加密檔案傳輸 |
| 13 | 密封 (Seal) | 加密的內部訊息 |
| 1059 | 禮物包裝 | 外層加密封裝 |
| 10050 | DM 中繼器列表 | 用戶接收私訊的中繼器 |
NIP-17 vs NIP-04
| 特性 | NIP-04 | NIP-17 |
|---|---|---|
| 內容加密 | AES-CBC | NIP-44 (XChaCha20) |
| 發送者隱私 | 公開可見 | 完全隱藏 |
| 接收者隱私 | 公開可見 | p 標籤可見 |
| 時間戳 | 真實時間 | 隨機化(±2天) |
| 可否認性 | 無 | 有(未簽名訊息) |
| 群組訊息 | 不支援 | 支援(≤100 人) |
訊息結構
Kind 14 聊天訊息
Kind 14 是內部的聊天訊息,它是「未簽名」的事件(稱為 Rumor), 這提供了可否認性——接收者無法向第三方證明訊息確實來自發送者。
{`{
"pubkey": "<發送者公鑰>",
"created_at": 1234567890,
"kind": 14,
"tags": [
["p", "<接收者公鑰>"],
["p", "<接收者2公鑰>"],
["e", "<回覆的訊息ID>", "<中繼器>", "reply"],
["subject", "對話主題"]
],
"content": "這是私密訊息內容"
}`} 標籤說明
p- 接收者公鑰,可多個(群組訊息)e- 回覆的訊息 ID(可選)subject- 對話主題(可選)
Kind 15 檔案訊息
用於傳輸加密檔案:
{`{
"pubkey": "<發送者公鑰>",
"created_at": 1234567890,
"kind": 15,
"tags": [
["p", "<接收者公鑰>"],
["file", "<加密檔案URL>", "<加密算法>", "<解密金鑰>"],
["m", "image/jpeg"],
["x", "<原始檔案SHA-256>"],
["ox", "<加密檔案SHA-256>"],
["dim", "1920x1080"],
["blurhash", "<模糊雜湊>"],
["thumb", "<縮圖URL>"]
],
"content": ""
}`} 加密層次
NIP-17 使用三層結構來保護隱私:
Rumor(謠言)
未簽名的 kind 14/15 事件,包含實際訊息內容。 沒有 id 和 sig 欄位,提供可否認性。
Seal(密封)- Kind 13
由發送者簽名,內容是 NIP-44 加密的 Rumor。 時間戳隨機化,無標籤洩露資訊。
Gift Wrap(禮物包裝)- Kind 1059
由隨機一次性密鑰簽名,內容是 NIP-44 加密的 Seal。 只有 p 標籤暴露接收者。
實作
發送私訊
{`import {
finalizeEvent,
getPublicKey,
generateSecretKey
} from 'nostr-tools';
import * as nip44 from 'nostr-tools/nip44';
interface Rumor {
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
}
// 建立未簽名的 Rumor
function createRumor(
senderPubkey: string,
recipients: string[],
content: string,
replyTo?: string
): Rumor {
const tags: string[][] = recipients.map(p => ['p', p]);
if (replyTo) {
tags.push(['e', replyTo, '', 'reply']);
}
return {
pubkey: senderPubkey,
created_at: Math.floor(Date.now() / 1000),
kind: 14,
tags,
content,
};
}
// 建立 Seal(密封)
function createSeal(
senderPrivateKey: Uint8Array,
recipientPubkey: string,
rumor: Rumor
) {
// NIP-44 加密 Rumor
const conversationKey = nip44.getConversationKey(
senderPrivateKey,
recipientPubkey
);
const encryptedRumor = nip44.encrypt(
JSON.stringify(rumor),
conversationKey
);
// 隨機化時間戳(±2天)
const randomOffset = Math.floor(Math.random() * 172800) - 86400;
const randomTime = Math.floor(Date.now() / 1000) + randomOffset;
return finalizeEvent({
kind: 13,
created_at: randomTime,
tags: [],
content: encryptedRumor,
}, senderPrivateKey);
}
// 建立 Gift Wrap(禮物包裝)
function createGiftWrap(
recipientPubkey: string,
seal: Event
) {
// 產生一次性隨機密鑰
const ephemeralKey = generateSecretKey();
// NIP-44 加密 Seal
const conversationKey = nip44.getConversationKey(
ephemeralKey,
recipientPubkey
);
const encryptedSeal = nip44.encrypt(
JSON.stringify(seal),
conversationKey
);
// 隨機化時間戳
const randomOffset = Math.floor(Math.random() * 172800) - 86400;
const randomTime = Math.floor(Date.now() / 1000) + randomOffset;
return finalizeEvent({
kind: 1059,
created_at: randomTime,
tags: [['p', recipientPubkey]],
content: encryptedSeal,
}, ephemeralKey);
}
// 完整發送流程
async function sendPrivateMessage(
senderPrivateKey: Uint8Array,
recipients: string[],
content: string
) {
const senderPubkey = getPublicKey(senderPrivateKey);
// 1. 建立 Rumor
const rumor = createRumor(senderPubkey, recipients, content);
// 2. 對每個接收者分別包裝
const giftWraps = recipients.map(recipient => {
const seal = createSeal(senderPrivateKey, recipient, rumor);
return createGiftWrap(recipient, seal);
});
return giftWraps;
}`} 接收私訊
{`import { verifyEvent } from 'nostr-tools';
import * as nip44 from 'nostr-tools/nip44';
interface DecryptedMessage {
sender: string;
recipients: string[];
content: string;
createdAt: number;
replyTo?: string;
}
// 解開禮物包裝
function unwrapGiftWrap(
recipientPrivateKey: Uint8Array,
giftWrap: Event
): DecryptedMessage {
const recipientPubkey = getPublicKey(recipientPrivateKey);
// 驗證這是發給我的
const pTag = giftWrap.tags.find(t => t[0] === 'p');
if (!pTag || pTag[1] !== recipientPubkey) {
throw new Error('This message is not for me');
}
// 1. 解密 Gift Wrap → Seal
const wrapConversationKey = nip44.getConversationKey(
recipientPrivateKey,
giftWrap.pubkey // 一次性公鑰
);
const sealJson = nip44.decrypt(giftWrap.content, wrapConversationKey);
const seal = JSON.parse(sealJson);
// 2. 驗證 Seal 簽名
if (!verifyEvent(seal)) {
throw new Error('Invalid seal signature');
}
// 3. 解密 Seal → Rumor
const sealConversationKey = nip44.getConversationKey(
recipientPrivateKey,
seal.pubkey // 發送者公鑰
);
const rumorJson = nip44.decrypt(seal.content, sealConversationKey);
const rumor = JSON.parse(rumorJson);
// 4. 驗證 Rumor 的發送者與 Seal 一致
if (rumor.pubkey !== seal.pubkey) {
throw new Error('Sender pubkey mismatch');
}
// 5. 提取資訊
const recipients = rumor.tags
.filter((t: string[]) => t[0] === 'p')
.map((t: string[]) => t[1]);
const replyTag = rumor.tags.find(
(t: string[]) => t[0] === 'e' && t[3] === 'reply'
);
return {
sender: seal.pubkey,
recipients,
content: rumor.content,
createdAt: rumor.created_at,
replyTo: replyTag?.[1],
};
}`} 私訊中繼器 (Kind 10050)
用戶應該發布 kind 10050 事件,指定接收私訊的中繼器:
{`// 發布私訊中繼器列表
function publishDMRelays(
privateKey: Uint8Array,
relays: string[]
) {
const event = finalizeEvent({
kind: 10050,
created_at: Math.floor(Date.now() / 1000),
tags: relays.map(r => ['relay', r]),
content: '',
}, privateKey);
return event;
}
// 查詢用戶的私訊中繼器
async function getDMRelays(
pool: SimplePool,
pubkey: string,
defaultRelays: string[]
): Promise {
const event = await pool.get(defaultRelays, {
kinds: [10050],
authors: [pubkey],
});
if (!event) return defaultRelays;
return event.tags
.filter(t => t[0] === 'relay')
.map(t => t[1]);
}`} 聊天室識別
聊天室由發送者公鑰加上所有 p 標籤的組合來識別。 添加或移除參與者會建立新的聊天室。
{`// 計算聊天室 ID
function getChatRoomId(
participants: string[]
): string {
// 排序參與者公鑰以確保一致性
const sorted = [...participants].sort();
return sorted.join(',');
}
// 根據訊息取得聊天室
function getChatRoomFromMessage(
message: DecryptedMessage
): string {
const allParticipants = [
message.sender,
...message.recipients
];
return getChatRoomId(allParticipants);
}`} 查詢私訊
{`import { SimplePool } from 'nostr-tools';
const pool = new SimplePool();
// 查詢我的私訊
async function getMyMessages(
myPrivateKey: Uint8Array,
relays: string[],
since?: number
) {
const myPubkey = getPublicKey(myPrivateKey);
// 查詢發給我的 Gift Wrap
const giftWraps = await pool.querySync(relays, {
kinds: [1059],
'#p': [myPubkey],
since,
});
// 解密所有訊息
const messages: DecryptedMessage[] = [];
for (const wrap of giftWraps) {
try {
const message = unwrapGiftWrap(myPrivateKey, wrap);
messages.push(message);
} catch (e) {
// 忽略無法解密的訊息
console.error('Failed to decrypt:', e);
}
}
// 按時間排序
return messages.sort((a, b) => a.createdAt - b.createdAt);
}
// 訂閱即時私訊
function subscribeToMessages(
myPrivateKey: Uint8Array,
relays: string[],
onMessage: (message: DecryptedMessage) => void
) {
const myPubkey = getPublicKey(myPrivateKey);
return pool.subscribeMany(
relays,
[{ kinds: [1059], '#p': [myPubkey] }],
{
onevent: (wrap) => {
try {
const message = unwrapGiftWrap(myPrivateKey, wrap);
onMessage(message);
} catch (e) {
console.error('Failed to decrypt:', e);
}
},
}
);
}`} 中繼器保護
支援 NIP-42 認證的中繼器可以只向 p 標籤中的用戶提供 kind 1059 事件, 進一步保護隱私:
中繼器建議
中繼器可以要求 NIP-42 認證後才提供 kind 1059 事件。 這樣只有訊息的實際接收者才能查詢到自己的私訊。
安全注意事項
- 時間戳隨機化:必須在 ±2 天範圍內隨機化,防止時序分析
- 一次性密鑰:每個 Gift Wrap 必須使用新的隨機密鑰
- 獨立包裝:發給多人時,每人需要獨立的 Gift Wrap
- 驗證簽名:解密後必須驗證 Seal 的簽名
- 公鑰匹配:確認 Rumor 的 pubkey 與 Seal 的簽名者一致
- 群組限制:群組成員不應超過 100 人,否則效率太低
限制
- 效率:每個接收者需要獨立的加密事件,大群組不實用
- 無前向保密:私鑰洩露會暴露所有過去的訊息
- 接收者可見:Gift Wrap 的
p標籤暴露接收者 - 儲存空間:三層結構增加了事件大小
注意
對於需要前向保密(Forward Secrecy)的高度敏感通訊, 考慮使用支援 PFS 的專用協議。NIP-17 適合一般私訊需求。
相關 NIP
- NIP-01 - 基本協議與事件結構
- NIP-04 - 加密直接訊息(舊版,不建議使用)
- NIP-44 - 加密訊息 v2(NIP-17 使用的加密)
- NIP-59 - 禮物包裝(NIP-17 的基礎機制)
- NIP-42 - 中繼器認證