高級
NIP-59 禮物包裝
Nostr 禮物包裝機制,用於隱藏訊息中繼資料,保護發送者和接收者隱私。
12 分鐘
概述
NIP-59 定義了「禮物包裝」(Gift Wrap)機制,用於隱藏 Nostr 訊息的中繼資料。 傳統的加密訊息雖然內容是加密的,但發送者、接收者和時間戳都是公開的。 禮物包裝通過多層加密和隨機化,完全隱藏這些中繼資料。
隱私保護
禮物包裝隱藏:發送者身份、接收者身份、實際發送時間、訊息類型。 中繼器只能看到隨機的一次性公鑰和隨機時間戳。
三層結構
禮物包裝使用三層結構來保護隱私:
| 層 | Kind | 說明 | 簽名者 |
|---|---|---|---|
| 1. 內容 | 任意 | 原始訊息(如 kind 14 私訊) | 發送者 |
| 2. 密封 | 13 | 加密的內容事件 | 發送者 |
| 3. 禮物包裝 | 1059 | 加密的密封事件 | 隨機一次性密鑰 |
謠言事件 (Rumor)
「謠言」是未簽名的事件,包含原始訊息內容。它有正常的事件結構, 但沒有 id 和 sig 欄位。
{`{
"pubkey": "<發送者公鑰>",
"created_at": 1234567890,
"kind": 14,
"tags": [
["p", "<接收者公鑰>"]
],
"content": "這是一則私密訊息"
}`} 密封事件 (Kind 13)
密封事件包含加密的謠言,使用 NIP-44 加密。密封事件由發送者簽名, 但 created_at 應該隨機化以隱藏實際時間。
{`{
"id": "<事件 ID>",
"pubkey": "<發送者公鑰>",
"created_at": <隨機時間戳>,
"kind": 13,
"tags": [],
"content": "",
"sig": "<發送者簽名>"
}`} 禮物包裝 (Kind 1059)
禮物包裝是最外層,使用一次性隨機密鑰簽名。這完全隱藏了發送者身份。 接收者的公鑰放在 p 標籤中,以便接收者的客戶端可以找到它。
{`{
"id": "<事件 ID>",
"pubkey": "<隨機一次性公鑰>",
"created_at": <隨機時間戳>,
"kind": 1059,
"tags": [
["p", "<接收者公鑰>"]
],
"content": "",
"sig": "<一次性密鑰簽名>"
}`} 實作流程
發送訊息
{`import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools';
import { encrypt } from 'nostr-tools/nip44';
// 建立謠言(未簽名的訊息)
function createRumor(
senderPubkey: string,
recipientPubkey: string,
content: string
) {
return {
pubkey: senderPubkey,
created_at: Math.floor(Date.now() / 1000),
kind: 14, // 私訊
tags: [['p', recipientPubkey]],
content,
};
}
// 建立密封事件
async function createSeal(
senderPrivateKey: Uint8Array,
recipientPubkey: string,
rumor: object
) {
const senderPubkey = getPublicKey(senderPrivateKey);
// 使用 NIP-44 加密謠言
const encryptedRumor = await encrypt(
senderPrivateKey,
recipientPubkey,
JSON.stringify(rumor)
);
// 隨機化時間戳(±2天)
const randomOffset = Math.floor(Math.random() * 172800) - 86400;
const randomTime = Math.floor(Date.now() / 1000) + randomOffset;
const sealEvent = {
kind: 13,
created_at: randomTime,
tags: [],
content: encryptedRumor,
};
return finalizeEvent(sealEvent, senderPrivateKey);
}
// 建立禮物包裝
async function createGiftWrap(
recipientPubkey: string,
seal: object
) {
// 產生一次性隨機密鑰
const ephemeralKey = generateSecretKey();
// 使用 NIP-44 加密密封事件
const encryptedSeal = await encrypt(
ephemeralKey,
recipientPubkey,
JSON.stringify(seal)
);
// 隨機化時間戳
const randomOffset = Math.floor(Math.random() * 172800) - 86400;
const randomTime = Math.floor(Date.now() / 1000) + randomOffset;
const wrapEvent = {
kind: 1059,
created_at: randomTime,
tags: [['p', recipientPubkey]],
content: encryptedSeal,
};
return finalizeEvent(wrapEvent, ephemeralKey);
}
// 完整發送流程
async function sendGiftWrappedMessage(
senderPrivateKey: Uint8Array,
recipientPubkey: string,
message: string
) {
const senderPubkey = getPublicKey(senderPrivateKey);
// 1. 建立謠言
const rumor = createRumor(senderPubkey, recipientPubkey, message);
// 2. 密封謠言
const seal = await createSeal(senderPrivateKey, recipientPubkey, rumor);
// 3. 禮物包裝
const giftWrap = await createGiftWrap(recipientPubkey, seal);
return giftWrap;
}`} 接收訊息
{`import { decrypt } from 'nostr-tools/nip44';
import { verifyEvent } from 'nostr-tools';
// 解開禮物包裝
async function unwrapGiftWrap(
recipientPrivateKey: Uint8Array,
giftWrap: Event
) {
const recipientPubkey = getPublicKey(recipientPrivateKey);
// 驗證這是給我的
const pTag = giftWrap.tags.find(t => t[0] === 'p');
if (!pTag || pTag[1] !== recipientPubkey) {
throw new Error('This gift wrap is not for me');
}
// 1. 解密禮物包裝 -> 密封事件
const sealJson = await decrypt(
recipientPrivateKey,
giftWrap.pubkey, // 一次性公鑰
giftWrap.content
);
const seal = JSON.parse(sealJson);
// 2. 驗證密封事件簽名
if (!verifyEvent(seal)) {
throw new Error('Invalid seal signature');
}
// 3. 解密密封事件 -> 謠言
const rumorJson = await decrypt(
recipientPrivateKey,
seal.pubkey, // 發送者公鑰
seal.content
);
const rumor = JSON.parse(rumorJson);
// 4. 驗證謠言的發送者與密封一致
if (rumor.pubkey !== seal.pubkey) {
throw new Error('Rumor pubkey mismatch');
}
return {
sender: seal.pubkey,
content: rumor.content,
kind: rumor.kind,
tags: rumor.tags,
createdAt: rumor.created_at,
};
}`} 查詢禮物包裝
{`import { SimplePool } from 'nostr-tools';
const pool = new SimplePool();
const relays = ['wss://relay.damus.io', 'wss://nos.lol'];
// 查詢發給我的禮物包裝
async function getMyGiftWraps(myPubkey: string, since?: number) {
return await pool.querySync(relays, {
kinds: [1059],
'#p': [myPubkey],
since,
});
}
// 訂閱即時禮物包裝
function subscribeToGiftWraps(
myPubkey: string,
onMessage: (wrap: Event) => void
) {
return pool.subscribeMany(
relays,
[{ kinds: [1059], '#p': [myPubkey] }],
{
onevent: onMessage,
}
);
}`} NIP-17 私訊
NIP-17 定義了基於禮物包裝的私訊標準,使用 kind 14 作為內部訊息類型:
{`// NIP-17 私訊的謠言結構
const directMessageRumor = {
pubkey: senderPubkey,
created_at: Math.floor(Date.now() / 1000),
kind: 14,
tags: [
['p', recipientPubkey],
// 可選:回覆某則訊息
['e', '<回覆的訊息 ID>', '<中繼器>', 'reply'],
],
content: '私訊內容',
};
// 群組私訊(發給多人)
async function sendGroupDM(
senderPrivateKey: Uint8Array,
recipients: string[],
message: string
) {
const senderPubkey = getPublicKey(senderPrivateKey);
// 對每個接收者分別包裝
const wraps = await Promise.all(
recipients.map(async (recipient) => {
const rumor = {
pubkey: senderPubkey,
created_at: Math.floor(Date.now() / 1000),
kind: 14,
tags: recipients.map(r => ['p', r]),
content: message,
};
const seal = await createSeal(senderPrivateKey, recipient, rumor);
return createGiftWrap(recipient, seal);
})
);
return wraps;
}`} 安全注意事項
- 時間戳隨機化:必須隨機化以防止時序分析
- 一次性密鑰:每個禮物包裝必須使用新的隨機密鑰
- 不要重複使用:同一訊息發給多人時,每人需要獨立包裝
- 驗證簽名:解密後必須驗證密封事件的簽名
- 公鑰匹配:確認謠言的公鑰與密封的簽名者一致
與 NIP-04 比較
| 特性 | NIP-04 | NIP-59 |
|---|---|---|
| 內容加密 | ✓ | ✓ |
| 隱藏發送者 | ✗ | ✓ |
| 隱藏接收者 | ✗ | 部分(p 標籤可見) |
| 隱藏時間 | ✗ | ✓ |
| 加密演算法 | AES-CBC | NIP-44 (XChaCha20) |
相關 NIP
參考資源
已複製連結