使用隐形地址
你是比尔(Bill)。出于某些我们不深究的原因,你想向“爱丽丝(Alice)竞选世界女王”的活动捐款,并希望爱丽丝知道你捐了款,这样如果她赢了就会奖励你。不幸的是,她并不能保证获胜。还有一个竞争活动:“卡罗尔(Carol)竞选太阳系女皇”。如果卡罗尔赢了,并且她发现你给爱丽丝捐了款,你就会有麻烦。所以你不能直接从你的账户向爱丽丝转账 200 ETH。
ERC-5564 (opens in a new tab) 提供了解决方案。该 ERC 解释了如何使用隐形地址 (opens in a new tab)进行匿名转账。
警告:据我们所知,隐形地址背后的密码学是可靠的。然而,存在潜在的侧信道攻击。在下文中,你将了解可以采取哪些措施来降低这种风险。
隐形地址的工作原理
本文将尝试通过两种方式解释隐形地址。第一种是如何使用它们。这部分足以让你理解本文的其余内容。然后是对其背后数学原理的解释。如果你对密码学感兴趣,也可以阅读这部分。
简单版本(如何使用隐形地址)
爱丽丝创建两个私钥并发布相应的公钥(可以组合成一个双倍长度的元地址)。比尔也创建一个私钥并发布相应的公钥。
使用一方的公钥和另一方的私钥,你可以推导出一个只有爱丽丝和比尔知道的共享秘密(它不能仅从公钥推导出来)。使用这个共享秘密,比尔获得了隐形地址并可以向其发送资产。
爱丽丝也从共享秘密中获得了该地址,但因为她知道她发布的公钥对应的私钥,她也可以获得允许她从该地址提取资产的私钥。
数学原理(为什么隐形地址是这样工作的)
标准的隐形地址使用椭圆曲线密码学(ECC) (opens in a new tab),以更少的密钥位数获得更好的性能,同时保持相同的安全级别。但在很大程度上,我们可以忽略这一点,假装我们使用的是常规算术。
有一个大家都知道的数字 G。你可以乘以 G。但由于 ECC 的性质,除以 G 实际上是不可能的。公钥密码学在以太坊中通常的工作方式是,你可以使用私钥 Ppriv 对交易进行签名,然后由公钥 Ppub = GPpriv 进行验证。
爱丽丝创建两个私钥,Kpriv 和 Vpriv。Kpriv 将用于从隐形地址中花费资金,而 Vpriv 用于查看属于爱丽丝的地址。然后爱丽丝发布公钥:Kpub = GKpriv 和 Vpub = GVpriv
比尔创建第三个私钥 Rpriv,并将 Rpub = GRpriv 发布到一个中央注册表(比尔也可以将其发送给爱丽丝,但我们假设卡罗尔在监听)。
比尔计算 RprivVpub = GRprivVpriv,他期望爱丽丝也知道这个值(下文解释)。这个值被称为 S,即共享秘密。这给了比尔一个公钥,Ppub = Kpub+G*hash(S)。从这个公钥中,他可以计算出一个地址,并向其发送他想要的任何资源。在未来,如果爱丽丝赢了,比尔可以告诉她 Rpriv,以证明资源来自他。
爱丽丝计算 RpubVpriv = GRprivVpriv。这给了她相同的共享秘密 S。因为她知道私钥 Kpriv,她可以计算出 Ppriv = Kpriv+hash(S)。这个密钥让她能够访问由 Ppub = GPpriv = GKpriv+G*hash(S) = Kpub+G*hash(S) 生成的地址中的资产。
我们有一个单独的查看密钥,以允许爱丽丝将工作分包给戴夫(Dave)的世界统治竞选服务公司。爱丽丝愿意让戴夫知道公共地址,并在有更多资金可用时通知她,但她不想让他花掉她的竞选资金。
因为查看和花费使用不同的密钥,爱丽丝可以把 Vpriv 给戴夫。然后戴夫可以计算 S = RpubVpriv = GRprivVpriv,从而获得公钥(Ppub = Kpub+G*hash(S))。但如果没有 Kpriv,戴夫就无法获得私钥。
总结一下,以下是不同参与者所知道的值。
| 爱丽丝 | 已发布 | 比尔 | 戴夫 |
|---|---|---|---|
| G | G | G | G |
| Kpriv | - | - | - |
| Vpriv | - | - | Vpriv |
| Kpub = GKpriv | Kpub | Kpub | Kpub |
| Vpub = GVpriv | Vpub | Vpub | Vpub |
| - | - | Rpriv | - |
| Rpub | Rpub | Rpub = GRpriv | Rpub |
| S = RpubVpriv = GRprivVpriv | - | S = RprivVpub = GRprivVpriv | S = RpubVpriv = GRprivVpriv |
| Ppub = Kpub+G*hash(S) | - | Ppub = Kpub+G*hash(S) | Ppub = Kpub+G*hash(S) |
| Address=f(Ppub) | - | Address=f(Ppub) | Address=f(Ppub) |
| Ppriv = Kpriv+hash(S) | - | - | - |
当隐形地址出现问题时
区块链上没有秘密。虽然隐形地址可以为你提供隐私,但这种隐私容易受到流量分析的影响。举个简单的例子,想象一下比尔为一个地址提供资金,并立即发送一笔交易来发布一个 Rpub 值。如果没有爱丽丝的 Vpriv,我们无法确定这是一个隐形地址,但很有可能就是。然后,我们看到另一笔交易将该地址的所有 ETH 转账到爱丽丝的竞选资金地址。我们可能无法证明这一点,但很可能比尔刚刚向爱丽丝的竞选活动捐了款。卡罗尔肯定会这么想。
比尔很容易将 Rpub 的发布与隐形地址的资金注入分开(在不同的时间、从不同的地址进行)。然而,这还不够。卡罗尔寻找的模式是比尔为一个地址提供资金,然后爱丽丝的竞选基金从中提取资金。
一种解决方案是爱丽丝的竞选团队不直接提取资金,而是用它来支付给第三方。如果爱丽丝的竞选团队向戴夫的世界统治竞选服务公司发送 10 ETH,卡罗尔只知道比尔向戴夫的一个客户捐了款。如果戴夫有足够的客户,卡罗尔将无法知道比尔是捐给了与她竞争的爱丽丝,还是捐给了卡罗尔不关心的亚当(Adam)、阿尔伯特(Albert)或阿比盖尔(Abigail)。爱丽丝可以在付款中包含一个哈希值,然后向戴夫提供原像,以证明这是她的捐款。或者,如上所述,如果爱丽丝把她的 Vpriv 给戴夫,他就已经知道付款来自谁了。
这个解决方案的主要问题是,它要求爱丽丝在保密对比尔有利时关心保密性。爱丽丝可能想维护自己的声誉,这样比尔的朋友鲍勃(Bob)也会向她捐款。但她也有可能不介意暴露比尔,因为那样他就会害怕如果卡罗尔赢了会发生什么。比尔最终可能会为爱丽丝提供更多的支持。
使用多个隐形层
比尔可以自己保护隐私,而不是依赖爱丽丝。他可以为虚构的人物鲍勃和贝拉(Bella)生成多个元地址。然后比尔将 ETH 发送给鲍勃,而“鲍勃”(实际上是比尔)将其发送给贝拉。“贝拉”(也是比尔)将其发送给爱丽丝。
卡罗尔仍然可以进行流量分析,并看到比尔到鲍勃到贝拉再到爱丽丝的管道。然而,如果“鲍勃”和“贝拉”也将 ETH 用于其他目的,那么即使爱丽丝立即从隐形地址提取资金到她已知的竞选地址,看起来也不像是比尔向爱丽丝转账了任何东西。
编写隐形地址应用程序
本文解释了一个在 GitHub 上可用的 (opens in a new tab)隐形地址应用程序。
工具
我们可以使用一个 TypeScript 隐形地址库 (opens in a new tab)。然而,密码学操作可能是 CPU 密集型的。我更喜欢用编译语言(如 Rust (opens in a new tab))来实现它们,并使用 Wasm (opens in a new tab) 在浏览器中运行代码。
我们将使用 Vite (opens in a new tab) 和 React (opens in a new tab)。这些是行业标准工具;如果你不熟悉它们,可以使用本教程。要使用 Vite,我们需要 Node。
查看隐形地址的实际应用
-
安装必要的工具:Rust (opens in a new tab) 和 Node (opens in a new tab)。
-
克隆 GitHub 仓库。
git clone https://github.com/qbzzt/251022-stealth-addresses.git cd 251022-stealth-addresses -
安装前置依赖并编译 Rust 代码。
cd src/rust-wasm rustup target add wasm32-unknown-unknown cargo install wasm-pack wasm-pack build --target web -
启动 Web 服务器。
cd ../.. npm install npm run dev -
浏览该应用程序 (opens in a new tab)。此应用程序页面有两个框架:一个用于爱丽丝的用户界面,另一个用于比尔的。这两个框架不进行通信;它们只是为了方便而放在同一个页面上。
-
作为爱丽丝,点击 Generate a Stealth Meta-Address(生成隐形元地址)。这将显示新的隐形地址和相应的私钥。将隐形元地址复制到剪贴板。
-
作为比尔,粘贴新的隐形元地址并点击 Generate an address(生成地址)。这为你提供了为爱丽丝提供资金的地址。
-
复制该地址和比尔的公钥,并将它们粘贴到爱丽丝用户界面的“Private key for address generated by Bill”(比尔生成的地址的私钥)区域。填写这些字段后,你将看到访问该地址资产的私钥。
-
你可以使用在线计算器 (opens in a new tab)来确保私钥与地址相对应。
程序是如何工作的
WASM 组件
编译成 WASM 的源代码是用 Rust (opens in a new tab) 编写的。你可以在 src/rust_wasm/src/lib.rs (opens in a new tab) 中看到它。这段代码主要是 JavaScript 代码和 eth-stealth-addresses 库 (opens in a new tab)之间的接口。
Cargo.toml
Rust 中的 Cargo.toml (opens in a new tab) 类似于 JavaScript 中的 package.json (opens in a new tab)。它包含包信息、依赖声明等。
[package]
name = "rust-wasm"
version = "0.1.0"
edition = "2024"
[dependencies]
eth-stealth-addresses = "0.1.0"
hex = "0.4.3"
wasm-bindgen = "0.2.104"
getrandom = { version = "0.2", features = ["js"] }
getrandom (opens in a new tab) 包需要生成随机值。这不能通过纯算法手段完成;它需要访问物理过程作为熵源。此定义指定我们将通过询问我们正在运行的浏览器来获取该熵。
console_error_panic_hook = "0.1.7"
当 WASM 代码发生 panic 且无法继续时,这个库 (opens in a new tab)会为我们提供更有意义的错误消息。
[lib]
crate-type = ["cdylib", "rlib"]
生成 WASM 代码所需的输出类型。
lib.rs
这是实际的 Rust 代码。
use wasm_bindgen::prelude::*;
从 Rust 创建 WASM 包的定义。它们在此处 (opens in a new tab)有记录。
use eth_stealth_addresses::{
generate_stealth_meta_address,
generate_stealth_address,
compute_stealth_key
};
我们需要从 eth-stealth-addresses 库 (opens in a new tab)中获取的函数。
use hex::{decode,encode};
Rust 通常使用字节数组 (opens in a new tab)([u8; <size>])来表示值。但在 JavaScript 中,我们通常使用十六进制字符串。hex 库 (opens in a new tab)为我们在一种表示形式和另一种表示形式之间进行转换。
#[wasm_bindgen]
生成 WASM 绑定,以便能够从 JavaScript 调用此函数。
pub fn wasm_generate_stealth_meta_address() -> String {
返回具有多个字段的对象的最佳方法是返回一个 JSON 字符串。
let (address, spend_private_key, view_private_key) =
generate_stealth_meta_address();
generate_stealth_meta_address (opens in a new tab) 返回三个字段:
- 元地址(Kpub 和 Vpub)
- 查看私钥(Vpriv)
- 花费私钥(Kpriv)
元组 (opens in a new tab)语法让我们再次分离这些值。
format!("{{\"address\":\"{}\",\"view_private_key\":\"{}\",\"spend_private_key\":\"{}\"}}",
encode(address),
encode(view_private_key),
encode(spend_private_key)
)
}
使用 format! (opens in a new tab) 宏生成 JSON 编码的字符串。使用 hex::encode (opens in a new tab) 将数组更改为十六进制字符串。
fn str_to_array<const N: usize>(s: &str) -> Option<[u8; N]> {
此函数将十六进制字符串(由 JavaScript 提供)转换为字节数组。我们使用它来解析 JavaScript 代码提供的值。由于 Rust 处理数组和向量的方式,这个函数很复杂。
<const N: usize> 表达式被称为泛型 (opens in a new tab)。N 是一个控制返回数组长度的参数。该函数实际上被称为 str_to_array::<n>,其中 n 是数组长度。
返回值为 Option<[u8; N]>,这意味着返回的数组是可选的 (opens in a new tab)。这是 Rust 中可能失败的函数的典型模式。
例如,如果我们调用 str_to_array::10("bad060a7"),该函数应该返回一个包含十个值的数组,但输入只有四个字节。该函数需要失败,它通过返回 None 来实现。对于 str_to_array::4("bad060a7") 的返回值将是 Some<[0xba, 0xd0, 0x60, 0xa7]>。
// decode 返回 Result<Vec<u8>, _>
let vec = decode(s).ok()?;
hex::decode (opens in a new tab) 函数返回一个 Result<Vec<u8>, FromHexError>。Result (opens in a new tab) 类型可以包含成功的结果(Ok(value))或错误(Err(error))。
.ok() 方法将 Result 转换为 Option,如果成功,其值为 Ok() 值,如果不成功,则为 None。最后,如果 Option 为空,问号运算符 (opens in a new tab)将中止当前函数并返回 None。否则,它会解包该值并返回它(在这种情况下,是为了给 vec 赋值)。
这看起来像是一种处理错误的奇怪而复杂的方法,但 Result 和 Option 确保所有错误都能以某种方式得到处理。
if vec.len() != N { return None; }
如果字节数不正确,那就是失败,我们返回 None。
// try_into 消耗 vec 并尝试转换为 [u8; N]
let array: [u8; N] = vec.try_into().ok()?;
Rust 有两种数组类型。数组(Array) (opens in a new tab)具有固定大小。向量(Vector) (opens in a new tab)可以增长和缩小。hex::decode 返回一个向量,但 eth_stealth_addresses 库希望接收数组。.try_into() (opens in a new tab) 将一个值转换为另一种类型,例如,将向量转换为数组。
Some(array)
}
在函数末尾返回值时,Rust 不要求你使用 return (opens in a new tab) 关键字。
#[wasm_bindgen]
pub fn wasm_generate_stealth_address(stealth_address: &str) -> Option<String> {
此函数接收一个公共元地址,其中包括 Vpub 和 Kpub。它返回隐形地址、要发布的公钥(Rpub)以及一个单字节的扫描值,该扫描值可加速识别哪些已发布的地址可能属于爱丽丝。
扫描值是共享秘密(S = GRprivVpriv)的一部分。这个值对爱丽丝可用,并且检查它比检查 f(Kpub+G*hash(S)) 是否等于已发布的地址要快得多。
let (address, r_pub, scan) =
generate_stealth_address(&str_to_array::<66>(stealth_address)?);
我们使用该库的 generate_stealth_address (opens in a new tab)。
format!("{{\"address\":\"{}\",\"rPub\":\"{}\",\"scan\":\"{}\"}}",
encode(address),
encode(r_pub),
encode(&[scan])
).into()
}
准备 JSON 编码的输出字符串。
#[wasm_bindgen]
pub fn wasm_compute_stealth_key(
address: &str,
bill_pub_key: &str,
view_private_key: &str,
spend_private_key: &str
) -> Option<String> {
.
.
.
}
此函数使用该库的 compute_stealth_key (opens in a new tab) 来计算从该地址提取资金的私钥(Rpriv)。此计算需要以下值:
- 地址(Address=f(Ppub))
- 比尔生成的公钥(Rpub)
- 查看私钥(Vpriv)
- 花费私钥(Kpriv)
#[wasm_bindgen(start)]
#[wasm_bindgen(start)] (opens in a new tab) 指定在初始化 WASM 代码时执行该函数。
pub fn main() {
console_error_panic_hook::set_once();
}
此代码指定将 panic 输出发送到 JavaScript 控制台。要查看其实际效果,请使用该应用程序并给比尔一个无效的元地址(只需更改一个十六进制数字)。你将在 JavaScript 控制台中看到此错误:
rust_wasm.js:236 panicked at /home/ori/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/subtle-2.6.1/src/lib.rs:701:9:
assertion `left == right` failed
left: 0
right: 1
随后是堆栈跟踪。然后给比尔有效的元地址,并给爱丽丝一个无效的地址或无效的公钥。你将看到此错误:
rust_wasm.js:236 panicked at /home/ori/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/eth-stealth-addresses-0.1.0/src/lib.rs:78:9:
keys do not generate stealth address
同样,随后是堆栈跟踪。
用户界面
用户界面使用 React (opens in a new tab) 编写,并由 Vite (opens in a new tab) 提供服务。你可以通过本教程了解它们。这里不需要 Wagmi (opens in a new tab),因为我们不直接与区块链或钱包交互。
用户界面中唯一不明显的部分是 WASM 连接。以下是它的工作原理。
vite.config.js
此文件包含 Vite 配置 (opens in a new tab)。
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import wasm from "vite-plugin-wasm";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), wasm()],
})
我们需要两个 Vite 插件:react (opens in a new tab) 和 wasm (opens in a new tab)。
App.jsx
此文件是应用程序的主要组件。它是一个包含两个组件的容器:Alice 和 Bill,即这些用户的用户界面。与 WASM 相关的部分是初始化代码。
import init from './rust-wasm/pkg/rust_wasm.js'
当我们使用 wasm-pack (opens in a new tab) 时,它会创建我们在这里使用的两个文件:一个包含实际代码的 wasm 文件(这里是 src/rust-wasm/pkg/rust_wasm_bg.wasm)和一个包含使用定义的 JavaScript 文件(这里是 src/rust_wasm/pkg/rust_wasm.js)。该 JavaScript 文件的默认导出是启动 WASM 需要运行的代码。
function App() {
.
.
.
useEffect(() => {
const loadWasm = async () => {
try {
await init();
setWasmReady(true)
} catch (err) {
console.error('Error loading wasm:', err)
alert("Wasm error: " + err)
}
}
loadWasm()
}, []
)
useEffect 钩子 (opens in a new tab)允许你指定一个在状态变量更改时执行的函数。在这里,状态变量列表为空([]),因此该函数仅在页面加载时执行一次。
effect 函数必须立即返回。要使用异步代码,例如 WASM init(它必须加载 .wasm 文件,因此需要时间),我们定义一个内部的 async (opens in a new tab) 函数,并在没有 await 的情况下运行它。
Bill.jsx
这是比尔的用户界面。它只有一个操作,即根据爱丽丝提供的隐形元地址创建一个地址。
import { wasm_generate_stealth_address } from './rust-wasm/pkg/rust_wasm.js'
除了默认导出之外,由 wasm-pack 生成的 JavaScript 代码还为 WASM 代码中的每个函数导出一个函数。
<button onClick={() => {
setPublicAddress(JSON.parse(wasm_generate_stealth_address(stealthMetaAddress)))
}}>
要调用 WASM 函数,我们只需调用由 wasm-pack 创建的 JavaScript 文件导出的函数。
Alice.jsx
Alice.jsx 中的代码类似,除了爱丽丝有两个操作:
- 生成元地址
- 获取比尔发布的地址的私钥
结论
隐形地址不是万能药;它们必须被正确使用。但如果使用得当,它们可以在公共区块链上实现隐私。