NUT-28:支付到盲化密钥(P2BK)

cashubtc 发布于 2026-02-17 阅读 96

NUT-28 描述了 Pay-to-Blinded-Key (P2BK),一种基于 NUT-11 的支付条件扩展,通过 ECDH 派生盲化因子 rᵢ 对接收方公钥进行盲化,从而防止铸币厂关联同一方的多次 P2PK 支出。文章详细说明了 ECDH 共享秘密生成、盲化公钥推导过程、证明对象扩展、密钥派生方法以及发送方和接收方的工作流程,并提供了 TypeScript 示例代码。

摘要

本文档描述了支付到盲化密钥(P2BK),它扩展了 [NUT-11][11](P2PK)的花费条件。因此,它也扩展了 [NUT-14][14](HTLC)。

P2BK 通过使用 ECDH 派生的标量 $rᵢ$ 盲化每个 NUT-11 接收者公钥 $P$,从而保护隐私。双方都可以从自己的密钥确定性地派生出相同的 $rᵢ$,但第三方无法做到。这防止了 mint 将同一方的多个 P2PK 花费关联起来,从而提升了用户隐私。

ECDH 共享秘密($Zx$)

椭圆曲线 Diffie–Hellman(ECDH)允许双方将自己的私钥与对方的公钥组合,创建一个 x 坐标共享秘密($Zx$):$Zx = x(epG) = x(eP) = x(pE)$。

对于 P2BK,发送方创建一个临时密钥对(私钥:$e$,公钥:$E$),这保护了他们通常使用的长期公钥的隐私。然后,他们通过组合临时私钥($e$)和接收方的长期公钥($P$)来计算共享秘密。

接收方使用自己的私钥($p$)和发送方在 proof 元数据 中提供的临时公钥($E$)来计算相同的共享秘密 $Zx$。

共享秘密 $Zx$ 随后用于派生盲化公钥。

派生盲化公钥

根据 NUT-11,最多有 11 个锁定“插槽”,顺序为:[data, ...pubkeys, ...refund]

插槽 0 是 data 标签。插槽 1-10 可以是 pubkeysrefund 密钥的任意组合。

NUT-11 proof 中的每个公钥都使用确定性的盲化标量($rᵢ$)进行永久盲化,其中 $i$ 是_插槽索引_。

每个插槽的盲化标量计算如下:

$$rᵢ = SHA-256( DOMAIN_SEPARATOR \parallel Zx \parallel i_byte)$$

其中:

  • DOMAIN_SEPARATOR 常量字节串 b"Cashu_P2BK_v1"
  • $Zx$ 是 ECDH 共享秘密(发送方为 $eP$,接收方为 $pE$)。
  • $i_byte$ 是 $i$ 的单字节无符号表示:($0x00$ 到 $0x0A$)
  • $\parallel$ 表示拼接

如果 $rᵢ$ 不在 $1 \le rᵢ \le n-1$ 范围内,则再尝试一次,并在哈希输入末尾附加一个额外的 $0xff$ 字节,如下:

$$rᵢ = SHA-256( b"Cashu_P2BK_v1" \parallel Zx \parallel i_byte \parallel 0xff )$$

如果 $rᵢ$ 仍然不在 $1 \le rᵢ \le n-1$ 范围内,则中止并丢弃临时密钥对。

最后,插槽 $i$ 的公钥($P$)被盲化($P'$)如下:

$$P' = P + rᵢG$$

示例

以下是 TypeScript 中的示例实现。

function deriveP2BKBlindingTweakFromECDH(
  point: WeierstrassPoint<bigint>, // E 或 P
  scalar: bigint, // p 或 e
  slotIndex: number, // i
): bigint {
  // 计算仅 x 的 ECDH 共享点 (Zx)
  const Zx = point.multiply(scalar).toBytes(true).slice(1);
  const iByte = new Uint8Array([slotIndex & 0xff]);
  // 派生出确定性盲化因子 (r):
  // 注意:bytesToNumber 不会对模 n 进行归约
  let r: bigint = bytesToNumber(sha256(Bytes.concat(P2BK_DST, Zx, iByte)));
  if (r === 0n || r >= secp256k1.Point.CURVE().n) {
    // 极不可能到达这里!
    r = bytesToNumber(
      sha256(Bytes.concat(P2BK_DST, Zx, iByte, new Uint8Array([0xff]))),
    );
    if (r === 0n || r >= secp256k1.Point.CURVE().n) {
      // 天文学上不可能到达这里!
      throw new Error("P2BK: 盲化调整派生失败");
    }
  }
  return r;
}

有关插槽盲化的详细示例,请参阅[测试向量][tests]。

[!重要] 所有接收方密钥必须在 ECDH 和盲化之前采用压缩的 SEC1 格式(33 字节)。
发送方必须为 BIP-340 的仅 x 公钥(例如 Nostr)添加“02”前缀。

Proof 对象扩展

每个 proof 添加了一个新的元数据字段:

{
  "amount": int,
  "id": hex_str,
  "secret": str,          // 仍然是 ["P2PK", {...}]
  "C": hex_str,
  "p2pk_e": hex_str       // 新增:33 字节 SEC1 压缩的临时公钥 E
}
  • $p2pk_e$ 包含用于盲化的发送方临时公钥($E$)
  • "P2PK" 秘密内部的所有公钥都是盲化形式 $P'$
  • mint 看到的是标准的 P2PK 数据,并且不知道盲化的存在
  • 对于 Token V4 编码,$p2pk_e$ 字段名称为 pe,$E$ 编码为 33 字节的 CBOR bstr

派生私钥

使用 P2BK,NUT-11 公钥锁定密钥被永久盲化。Mint 只看到盲化公钥,并期望来自相应私钥的签名。

因此,接收方必须派生出正确的盲化私钥($k$)。由于 BIP-340 将公钥提升为偶数 Y 奇偶性,因此有两条可能的派生路径:

  • 标准派生:$k = (p + rᵢ) \bmod n$
  • 取反派生:$k = (-p + rᵢ) \bmod n$

其中 $p$ 是接收方的长期私钥。

为了决定使用哪种派生,接收方计算其自然公钥($pG$)并将其奇偶性与实际公钥($P$)进行比较。

如果奇偶性匹配,则使用标准派生,否则使用取反派生。

在钱包中最快的方法是解除盲化,验证密钥是否匹配,然后根据奇偶性选择派生:

a. 计算 $Rᵢ = rᵢG$
b. 解除盲化 $P = P' - Rᵢ$
c. 验证 $x(P) == x(pG)$
d. 如果 $parity(P) == parity(pG)$,则使用标准派生,否则使用取反派生

发送方工作流程

  1. 生成一个新的随机标量 $e$ 并计算 $E = eG$
  2. 对于每个接收方密钥 $P$,计算:
    a. 该密钥的唯一共享秘密:$Zx = x(eP)$
    b. 插槽索引 $i$,位于 [data, ...pubkeys, ...refund]
    c. 盲化标量:$rᵢ = SHA-256(b"Cashu_P2BK_v1" \parallel Zx \parallel i_byte)$
    d. 盲化公钥:$P' = P + rᵢG$
  3. 使用盲化后的 $P'$ 密钥按插槽位置构建标准的 P2PK 秘密。
  4. 正常与 mint 交互;mint 永远不会知道 $P$ 或 $rᵢ$
  5. 在最终的 proof 中包含 $p2pk_e = E$

[!重要] 为每个新输出使用新的临时密钥对($e$ / $E$),以便每个 proof 具有唯一的盲化密钥和 Proof.p2pk_e 字段中的唯一 $E$。

对于 SIG_ALL 的情况,必须为所有输出使用相同的临时密钥对,因为所有 SIG_ALL proof 秘密必须具有相同的 datatags 字段。

接收方工作流程

  1. proof.p2pk_e 读取 $E$,并从 [data, ...pubkeys, ...refund] 读取密钥插槽顺序索引 $i$
  2. 计算你的唯一共享秘密:$Zx = x(pE)$
  3. 对于每个插槽 $i$,计算:
    a. 盲化标量:$rᵢ = SHA-256(b"Cashu_P2BK_v1" \parallel Zx \parallel i_byte)$
    b. 计算 $Rᵢ = rᵢG$
    c. 解除盲化 $P = P' - Rᵢ$
    d. 验证 $x(P) == x(pG)$。如果不匹配,则该 $P'$ 不属于此私钥,跳过它。
    e. 使用以下方法派生私钥:
    • 如果 $parity(P) == parity(pG)$,则使用标准派生
    • 否则使用取反派生
  4. 从 proof 中移除 $p2pk_e$ 字段
  5. 使用派生出的私钥签名,像普通 P2PK proof 一样花费

[!注意] 每个接收方只能计算他们自己的共享秘密($pE$),因为共享秘密需要接收方的私钥($pE$)或发送方的临时私钥($eP$)。

  • 原文链接: github.com/cashubtc/nuts...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论