NIP-59:三层封装实现元数据隐藏

nostr-protocol 发布于 2025-12-24 阅读 70

NIP-59 定义了一种封装 nostr 事件的协议,通过 rumor(未签名事件)、seal(kind 13,加密的 rumor)和 gift wrap(kind 1059,加密的 seal)三层结构实现元数据隐藏。加密基于 NIP-44,seal 使用发送者私钥加密,gift wrap 使用一次性随机密钥加密,并包含收件人 p 标签。协议支持可否认性、协作签名,并建议调整时间戳防止时序分析,以及基于 AUTH 保护收件人元数据。文章包含完整示例和 JavaScript 代码。

礼物包装

可选 中继

本 NIP 定义了一种封装任意 nostr 事件的协议。它可以隐藏给定事件的大部分元数据,支持协作签名等。

本 NIP 定义任何消息协议。本 NIP 的应用应单独定义。

本 NIP 依赖于 NIP-44 的版本化加密算法。

概述

该协议使用三个主要概念来保护目标事件的传输:谣言封印礼物包装

  • 谣言 是一个常规的 nostr 事件,但未签名。这意味着一旦泄露,无法验证其真实性。
  • 谣言 被序列化为 JSON,加密后放入 封印content 字段。然后 封印 由笔记作者签名。封印 上公开的唯一信息是谁签署了它,而不是说了什么。
  • 封印 被序列化为 JSON,加密后放入 礼物包装content 字段。

这实现了跨层的关注点隔离:

  • 谣言携带内容但未签名,这意味着如果泄露,会被中继和客户端拒绝,且无法被认证。这提供了一定程度的可否认性。
  • 封印标识了作者,但不透露内容或接收者。
  • 礼物包装可以添加元数据(接收者、标签、不同的作者),而不暴露真正的作者。

协议描述

1. 谣言事件种类

谣言 与未签名事件相同。任何事件种类都可以通过移除签名变成 谣言

2. 封印事件种类

封印 是一个 kind:13 事件,它使用发送者的常规密钥包裹一个 谣言封印 始终加密到接收者的公钥,但没有指向接收者的 p 标签。如果没有接收者或发送者的私钥,就无法知道谣言是给谁的。此事件中唯一公开的信息是谁在签名。

{
  "id": "<id>",
  "pubkey": "<真实作者的公钥>",
  "content": "<加密的谣言>",
  "kind": 13,
  "created_at": 1686840217,
  "tags": [],
  "sig": "<真实作者公钥的签名>"
}

kind:13 中的标签必须始终为空。内部事件必须始终未签名。

3. 礼物包装事件种类

礼物包装 事件是一个 kind:1059 事件,它包裹任何其他事件。tags 应包含路由事件到预期接收者所需的任何信息,包括接收者的 p 标签或 NIP-13 工作量证明。

{
  "id": "<id>",
  "pubkey": "<随机的、一次性使用的公钥>",
  "content": "<加密的 kind 13>",
  "kind": 1059,
  "created_at": 1686840217,
  "tags": [["p", "<接收者公钥>"]],
  "sig": "<随机的、一次性使用公钥的签名>"
}

加密有效载荷

加密按照 NIP-44 对 JSON 编码的事件进行。将加密有效载荷放入包装事件(封印礼物包装)的 .content 中。

其他考虑

如果 谣言 是给多方准备的,或者作者希望保留一份加密副本,则可以为每个接收者单独包装和寻址一个 谣言

规范的 created_at 时间属于 谣言。所有其他时间戳应被调整以挫败时间分析攻击。注意,一些中继不提供未来的事件,因此所有时间戳应为过去。

中继可能会选择不存储礼物包装的事件,因为它们在公开用途上不大。客户端可选择根据 NIP-13 向包装事件附加一定量的工作量证明,以证明该事件不是垃圾邮件或拒绝服务攻击。

为了保护接收者元数据,中继应根据用户 AUTH 限制对 kind 1059 事件的访问。在可能的情况下,客户端应仅向提供此保护的中继发送包装事件。

为了保护接收者元数据,中继应仅提供针对标记接收者的 kind 1059 事件。在可能的情况下,客户端应仅将包装事件发送给接收者的 读取 中继,这些中继实现了 AUTH,并拒绝向非接收者提供包装事件。

封印礼物包装 层添加过期标签时,实现应为每层使用独立的随机时间戳。使用不同的 created_at 值可以增加时间方差,有助于防止元数据关联攻击。

由于签名密钥是随机的,中继应删除那些其 p 标签与 NIP-09 删除或 NIP-62 消失请求的签名者匹配的 kind 1059 事件。

示例

让我们在两方之间发送一个包裹的 kind 1 消息,内容为“你今晚去参加派对吗?”

  • 作者私钥:0beebd062ec8735f4243466049d7747ef5d6594ee838de147f8aab842b15e273
  • 接收者私钥:e108399bd8424357a710b606ae0c13166d853d327e47a6e5e038197346bdbf45
  • 临时包装密钥:4f02eac59266002db5801adc5270700ca69d5b8f761d8732fab2fbf233c90cbd

请注意,此消息协议不应在实际中使用,这仅是一个示例。请参考其他依赖于礼物包装的具体消息协议的 NIP。

1. 创建事件

创建一个带有消息、接收者以及任何其他标签的 kind 1 事件,并由作者签名。不要对事件进行签名。

{
  "created_at": 1691518405,
  "content": "Are you going to the party tonight?",
  "tags": [],
  "kind": 1,
  "pubkey": "611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9",
  "id": "9dd003c6d3b73b74a85a9ab099469ce251653a7af76f523671ab828acd2a0ef9"
}

2. 封印谣言

使用作者的私钥和接收者的公钥派生的对话密钥加密 JSON 编码的 谣言。将结果放入 kind 13 封印 事件的 content 字段。用作者的密钥签名。

{
  "content": "AqBCdwoS7/tPK+QGkPCadJTn8FxGkd24iApo3BR9/M0uw6n4RFAFSPAKKMgkzVMoRyR3ZS/aqATDFvoZJOkE9cPG/TAzmyZvr/WUIS8kLmuI1dCA+itFF6+ULZqbkWS0YcVU0j6UDvMBvVlGTzHz+UHzWYJLUq2LnlynJtFap5k8560+tBGtxi9Gx2NIycKgbOUv0gEqhfVzAwvg1IhTltfSwOeZXvDvd40rozONRxwq8hjKy+4DbfrO0iRtlT7G/eVEO9aJJnqagomFSkqCscttf/o6VeT2+A9JhcSxLmjcKFG3FEK3Try/WkarJa1jM3lMRQqVOZrzHAaLFW/5sXano6DqqC5ERD6CcVVsrny0tYN4iHHB8BHJ9zvjff0NjLGG/v5Wsy31+BwZA8cUlfAZ0f5EYRo9/vKSd8TV0wRb9DQ=",
  "kind": 13,
  "created_at": 1703015180,
  "pubkey": "611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9",
  "tags": [],
  "id": "28a87d7c074d94a58e9e89bb3e9e4e813e2189f285d797b1c56069d36f59eaa7",
  "sig": "02fc3facf6621196c32912b1ef53bac8f8bfe9db51c0e7102c073103586b0d29c3f39bdaa1e62856c20e90b6c7cc5dc34ca8bb6a528872cf6e65e6284519ad73"
}

3. 包装封印

使用你的临时、一次性随机密钥加密 JSON 编码的 kind 13 事件。将结果放入 kind 1059content 字段。添加一个包含接收者公钥的 p 标签。使用上一步生成的随机密钥签名 礼物包装

{
  "content": "AhC3Qj/QsKJFWuf6xroiYip+2yK95qPwJjVvFujhzSguJWb/6TlPpBW0CGFwfufCs2Zyb0JeuLmZhNlnqecAAalC4ZCugB+I9ViA5pxLyFfQjs1lcE6KdX3euCHBLAnE9GL/+IzdV9vZnfJH6atVjvBkNPNzxU+OLCHO/DAPmzmMVx0SR63frRTCz6Cuth40D+VzluKu1/Fg2Q1LSst65DE7o2efTtZ4Z9j15rQAOZfE9jwMCQZt27rBBK3yVwqVEriFpg2mHXc1DDwHhDADO8eiyOTWF1ghDds/DxhMcjkIi/o+FS3gG1dG7gJHu3KkGK5UXpmgyFKt+421m5o++RMD/BylS3iazS1S93IzTLeGfMCk+7IKxuSCO06k1+DaasJJe8RE4/rmismUvwrHu/HDutZWkvOAhd4z4khZo7bJLtiCzZCZ74lZcjOB4CYtuAX2ZGpc4I1iOKkvwTuQy9BWYpkzGg3ZoSWRD6ty7U+KN+fTTmIS4CelhBTT15QVqD02JxfLF7nA6sg3UlYgtiGw61oH68lSbx16P3vwSeQQpEB5JbhofW7t9TLZIbIW/ODnI4hpwj8didtk7IMBI3Ra3uUP7ya6vptkd9TwQkd/7cOFaSJmU+BIsLpOXbirJACMn+URoDXhuEtiO6xirNtrPN8jYqpwvMUm5lMMVzGT3kMMVNBqgbj8Ln8VmqouK0DR+gRyNb8fHT0BFPwsHxDskFk5yhe5c/2VUUoKCGe0kfCcX/EsHbJLUUtlHXmTqaOJpmQnW1tZ/siPwKRl6oEsIJWTUYxPQmrM2fUpYZCuAo/29lTLHiHMlTbarFOd6J/ybIbICy2gRRH/LFSryty3Cnf6aae+A9uizFBUdCwTwffc3vCBae802+R92OL78bbqHKPbSZOXNC+6ybqziezwG+OPWHx1Qk39RYaF0aFsM4uZWrFic97WwVrH5i+/Nsf/OtwWiuH0gV/SqvN1hnkxCTF/+XNn/laWKmS3e7wFzBsG8+qwqwmO9aVbDVMhOmeUXRMkxcj4QreQkHxLkCx97euZpC7xhvYnCHarHTDeD6nVK+xzbPNtzeGzNpYoiMqxZ9bBJwMaHnEoI944Vxoodf51cMIIwpTmmRvAzI1QgrfnOLOUS7uUjQ/IZ1Qa3lY08Nqm9MAGxZ2Ou6R0/Z5z30ha/Q71q6meAs3uHQcpSuRaQeV29IASmye2A2Nif+lmbhV7w8hjFYoaLCRsdchiVyNjOEM4VmxUhX4VEvw6KoCAZ/XvO2eBF/SyNU3Of4SO",
  "kind": 1059,
  "created_at": 1703021488,
  "pubkey": "18b1a75918f1f2c90c23da616bce317d36e348bcf5f7ba55e75949319210c87c",
  "id": "5c005f3ccf01950aa8d131203248544fb1e41a0d698e846bd419cec3890903ac",
  "sig": "35fabdae4634eb630880a1896a886e40fd6ea8a60958e30b89b33a93e6235df750097b04f9e13053764251b8bc5dd7e8e0794a3426a90b6bcc7e5ff660f54259",
  "tags": [["p", "166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99"]],
}

4. 选择性广播

仅向接收者的中继广播 kind 1059 事件。删除所有其他事件。

代码示例

JavaScript

import {bytesToHex} from "@noble/hashes/utils"
import type {EventTemplate, UnsignedEvent, Event} from "nostr-tools"
import {getPublicKey, getEventHash, nip19, nip44, finalizeEvent, generateSecretKey} from "nostr-tools"

type Rumor = UnsignedEvent & {id: string}

const TWO_DAYS = 2 * 24 * 60 * 60

const now = () => Math.round(Date.now() / 1000)
const randomNow = () => Math.round(now() - (Math.random() * TWO_DAYS))

const nip44ConversationKey = (privateKey: Uint8Array, publicKey: string) =>
  nip44.v2.utils.getConversationKey(bytesToHex(privateKey), publicKey)

const nip44Encrypt = (data: EventTemplate, privateKey: Uint8Array, publicKey: string) =>
  nip44.v2.encrypt(JSON.stringify(data), nip44ConversationKey(privateKey, publicKey))

const nip44Decrypt = (data: Event, privateKey: Uint8Array) =>
  JSON.parse(nip44.v2.decrypt(data.content, nip44ConversationKey(privateKey, data.pubkey)))

const createRumor = (event: Partial<UnsignedEvent>, privateKey: Uint8Array) => {
  const rumor = {
    created_at: now(),
    content: "",
    tags: [],
    ...event,
    pubkey: getPublicKey(privateKey),
  } as any

  rumor.id = getEventHash(rumor)

  return rumor as Rumor
}

const createSeal = (rumor: Rumor, privateKey: Uint8Array, recipientPublicKey: string) => {
  return finalizeEvent(
    {
      kind: 13,
      content: nip44Encrypt(rumor, privateKey, recipientPublicKey),
      created_at: randomNow(),
      tags: [],
    },
    privateKey
  ) as Event
}

const createWrap = (event: Event, recipientPublicKey: string) => {
  const randomKey = generateSecretKey()

  return finalizeEvent(
    {
      kind: 1059,
      content: nip44Encrypt(event, randomKey, recipientPublicKey),
      created_at: randomNow(),
      tags: [["p", recipientPublicKey]],
    },
    randomKey
  ) as Event
}

// 使用上述示例的测试用例
const senderPrivateKey = nip19.decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
const recipientPrivateKey = nip19.decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data
const recipientPublicKey = getPublicKey(recipientPrivateKey)

const rumor = createRumor(
  {
    kind: 1,
    content: "Are you going to the party tonight?",
  },
  senderPrivateKey
)

const seal = createSeal(rumor, senderPrivateKey, recipientPublicKey)
const wrap = createWrap(seal, recipientPublicKey)

// 接收者使用他们的私钥解包

const unwrappedSeal = nip44Decrypt(wrap, recipientPrivateKey)
const unsealedRumor = nip44Decrypt(unwrappedSeal, recipientPrivateKey)
  • 原文链接: github.com/nostr-protoco...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论