使用隐身地址
本文详细介绍了以太坊上的隐身地址(Stealth Addresses)技术,基于ERC-5564标准,实现匿名转账。
你是比尔。出于某些原因,你希望向“Alice为世界女王”竞选活动捐款,并且让Alice知道你捐了款,这样她获胜后会奖励你。不幸的是,她的胜利并非板上钉钉。还有一个竞选的对手,“卡罗尔为太阳系女皇”。如果卡罗尔获胜,并且她发现你捐给了Alice,你就有麻烦了。所以你不能简单地从你的账户向Alice转账200 ETH。
ERC-5564 提供了解决方案。该ERC解释了如何使用隐身地址进行匿名转账。
警告:据我们所知,隐身地址背后的密码学是可靠的。然而,存在潜在的侧信道攻击。在下文中,你将看到如何降低这种风险。
隐身地址的工作原理
本文将尝试用两种方式解释隐身地址。第一种是如何使用它们。这部分足以理解文章的其余内容。然后,有其背后的数学解释。如果你对密码学感兴趣,也请阅读这一部分。
简单版(如何使用隐身地址)
Alice生成两个私钥,并发布对应的公钥(它们可以合并成单个双倍长度的元地址)。比尔也生成一个私钥并发布对应的公钥。
使用一方的公钥和另一方的私钥,你可以推导出一个只有Alice和比尔知道的共享秘密(不能仅从公钥推导)。利用这个共享秘密,比尔获得隐身地址,并可以向其发送资产。
Alice也从共享秘密获得地址,但由于她知道她所发布公钥对应的私钥,她也能获得让她从该地址提取资产的私钥。
数学原理(为什么隐身地址是这样工作的)
标准隐身地址使用椭圆曲线密码学(ECC)来以更少的密钥位获得更好的性能,同时保持相同级别的安全性。但在大多数情况下,我们可以忽略这一点,假设我们使用常规算术。
存在一个所有人都知道的数 $G$。你可以乘以 $G$。但由于ECC的性质,除以 $G$ 实际上是不可能的。以太坊中公钥密码学的一般工作方式是,你可以使用私钥 $P_{priv}$ 对交易进行签名,然后由公钥 $P_{pub} = G P_{priv}$ 验证。
Alice生成两个私钥 $K_{priv}$ 和 $V_{priv}$。$K_{priv}$ 将用于从隐身地址花费资金,$V_{priv}$ 用于查看属于Alice的地址。然后Alice发布公钥:$K_{pub} = G K_{priv}$ 和 $V_{pub} = G V_{priv}$
比尔生成第三个私钥 $R_{priv}$,并将 $R_{pub} = G R_{priv}$ 发布到中央注册表(比尔也可以将其发送给Alice,但我们假设卡罗尔在监听)。
比尔计算 $R_{priv} V_{pub} = G R_{priv} V_{priv}$,他期望Alice也知道这个值(下面解释)。这个值称为 $S$,即共享秘密。这给了比尔一个公钥 $P_{pub} = K_{pub} + G \cdot \text{hash}(S)$。从这个公钥,他可以计算出一个地址并向其发送任何他想要的资源。将来,如果Alice获胜,比尔可以告诉她 $R_{priv}$ 以证明资源来自他。
Alice计算 $R_{pub} V_{priv} = G R_{priv} V_{priv}$。这给了她相同的共享秘密 $S$。因为她知道私钥 $K_{priv}$,她可以计算出 $P_{priv} = K_{priv} + \text{hash}(S)$。这个密钥让她能够访问由 $P_{pub} = G P_{priv} = G K_{priv} + G \cdot \text{hash}(S) = K_{pub} + G \cdot \text{hash}(S)$ 产生的地址中的资产。
我们有一个单独的查看密钥,以便Alice可以将任务分包给戴夫的“世界统治竞选服务”。Alice愿意让戴夫知道公钥地址,并在有更多资金可用时通知她,但她不希望他动用她的竞选资金。
因为查看和花费使用不同的密钥,Alice可以将 $V_{priv}$ 交给戴夫。然后戴夫可以计算 $S = R_{pub} V_{priv} = G R_{priv} V_{priv}$,从而得到公钥($P_{pub} = K_{pub} + G \cdot \text{hash}(S)$)。但如果没有 $K_{priv}$,戴夫无法获得私钥。
总结一下,以下是不同参与者知道的值。
| Alice | 已发布 | 比尔 | 戴夫 |
|---|---|---|---|
| $G$ | $G$ | $G$ | $G$ |
| $K_{priv}$ | - | - | - |
| $V_{priv}$ | - | - | $V_{priv}$ |
| $K_{pub} = G K_{priv}$ | $K_{pub}$ | $K_{pub}$ | $K_{pub}$ |
| $V_{pub} = G V_{priv}$ | $V_{pub}$ | $V_{pub}$ | $V_{pub}$ |
| - | - | $R_{priv}$ | - |
| $R_{pub}$ | $R_{pub}$ | $R_{pub} = G R_{priv}$ | $R_{pub}$ |
| $S = R_{pub} V_{priv} = G R_{priv} V_{priv}$ | - | $S = R_{priv} V_{pub} = G R_{priv} V_{priv}$ | $S = R_{pub} V_{priv} = G R_{priv} V_{priv}$ |
| $P_{pub} = K_{pub} + G \cdot \text{hash}(S)$ | - | $P_{pub} = K_{pub} + G \cdot \text{hash}(S)$ | $P_{pub} = K_{pub} + G \cdot \text{hash}(S)$ |
| $\text{地址} = f(P_{pub})$ | - | $\text{地址} = f(P_{pub})$ | $\text{地址} = f(P_{pub})$ |
| $P_{priv} = K_{priv} + \text{hash}(S)$ | - | - | - |
当隐身地址出错时
区块链上没有秘密。虽然隐身地址可以为你提供隐私,但这种隐私容易受到流量分析的影响。举一个简单的例子:想象比尔向一个地址注入资金,然后立即发送一笔交易来发布一个 $R_{pub}$ 值。没有Alice的 $V_{priv}$,我们无法确定这是一个隐身地址,但这是很可能的猜测。然后,我们看到另一笔交易将该地址的所有ETH转移到Alice的竞选基金地址。我们可能无法证明,但比尔很可能刚刚向Alice的竞选活动捐款。卡罗尔肯定会这么认为。
比尔很容易将发布 $R_{pub}$ 与向隐身地址注资分开(在不同时间、不同地址进行)。然而,这还不够。卡罗尔寻找的模式是比尔向一个地址注资,然后Alice的竞选基金从中提取。
一种解决方案是Alice的竞选活动不直接提取资金,而是用它来支付给第三方。如果Alice的竞选活动向戴夫的“世界统治竞选服务”发送10 ETH,卡罗尔只知比尔向戴夫的一个客户捐款。如果戴夫有足够多的客户,卡罗尔就无法知道比尔是捐给了与她竞争的Alice,还是捐给了卡罗尔不在乎的亚当、阿尔伯特或阿比盖尔。Alice可以在付款中包含一个哈希值,然后向戴夫提供原像,以证明这是她的捐款。或者,如上所述,如果Alice将她的 $V_{priv}$ 交给戴夫,他已经知道付款来自谁。
这种方案的主要问题是它要求Alice关心对比尔有利的保密性。Alice可能想维护自己的声誉,以便比尔的朋友Bob也会向她捐款。但她也可能不介意暴露比尔,因为这样比尔会担心如果卡罗尔获胜会发生什么。比尔最终可能会给Alice提供更多支持。
使用多层隐身技术
比尔不依赖Alice来保护他的隐私,他可以自己做到。他可以为虚构人物Bob和贝拉生成多个元地址。然后比尔向Bob发送ETH,而“Bob”(实际上是比尔)将其发送给贝拉。“贝拉”(也是比尔)将其发送给Alice。
卡罗尔仍然可以进行流量分析,看到比尔->Bob->贝拉->Alice的管道。然而,如果“Bob”和“贝拉”也将ETH用于其他目的,那么即使Alice立即从隐身地址提取到她的已知竞选地址,也不会显得比尔向Alice转移了任何东西。
编写隐身地址应用程序
本文介绍一个在GitHub上可用的隐身地址应用程序。
工具
有一个我们可以使用的TypeScript隐身地址库。然而,密码学操作可能非常消耗CPU。我倾向于用编译型语言(如Rust)实现它们,并使用WASM在浏览器中运行代码。
我们将使用Vite和React。这些是行业标准工具;如果你不熟悉它们,可以参考本教程。要使用Vite,我们需要Node。
实际体验隐身地址
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
-
浏览到应用程序。该应用程序页面有两个框架:一个用于Alice的用户界面,另一个用于比尔的。这两个框架不通信;它们只是放在同一页面上以便于使用。
-
以Alice身份,点击生成隐身元地址。这将显示新的隐身地址和对应的私钥。将隐身元地址复制到剪贴板。
-
以比尔身份,粘贴新的隐身元地址并点击生成地址。这会给你用于向Alice注资的地址。
-
复制该地址和比尔的公钥,并将它们粘贴到Alice用户界面的“由比尔生成的地址的私钥”区域。一旦这些字段填写完毕,你将看到访问该地址资产的私钥。
-
你可以使用在线计算器来确保私钥对应于该地址。
程序如何工作
WASM组件
编译成WASM的源代码是用Rust编写的。你可以在src/rust_wasm/src/lib.rs中看到它。这段代码主要是JavaScript代码与eth-stealth-addresses库之间的接口。
Cargo.toml
Rust中的Cargo.toml类似于JavaScript中的package.json。它包含包信息、依赖声明等。
[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包需要生成随机值。这不能纯靠算法完成;它需要访问物理过程作为熵源。此定义指定我们将通过询问运行所在的浏览器来获取熵。
console_error_panic_hook = "0.1.7"
这个库在WASM代码崩溃且无法继续时提供更有意义的错误消息。
[lib]
crate-type = ["cdylib", "rlib"]
生成WASM代码所需的输出类型。
lib.rs
这是实际的Rust代码。
use wasm_bindgen::prelude::*;
创建WASM包所需的定义。它们记录在此处。
use eth_stealth_addresses::{
generate_stealth_meta_address,
generate_stealth_address,
compute_stealth_key
};
我们需要来自eth-stealth-addresses库的函数。
use hex::{decode,encode};
Rust通常使用字节数组([u8; <size>])来表示值。但在JavaScript中,我们通常使用十六进制字符串。hex库负责在两种表示之间进行转换。
#[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返回三个字段:
- 元地址($K_{pub}$ 和 $V_{pub}$)
- 查看私钥($V_{priv}$)
- 花费私钥($K_{priv}$)
元组语法让我们可以再次分离这些值。
format!("{{\"address\":\"{}\",\"view_private_key\":\"{}\",\"spend_private_key\":\"{}\"}}",
encode(address),
encode(view_private_key),
encode(spend_private_key)
)
}
使用format!宏生成JSON编码的字符串。使用hex::encode将数组转换为十六进制字符串。
fn str_to_array<const N: usize>(s: &str) -> Option<[u8; N]> {
此函数将JavaScript提供的十六进制字符串转换为字节数组。我们用它来解析由JavaScript代码提供的值。由于Rust处理数组和向量的方式,这个函数比较复杂。
<const N: usize>表达式称为泛型。N是一个控制返回数组长度的参数。该函数实际上叫做str_to_array::<n>,其中n是数组长度。
返回值是Option<[u8; N]>,表示返回的数组是可选的。这是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函数返回Result<Vec<u8>, FromHexError>。Result类型可以包含成功结果(Ok(value))或错误(Err(error))。
.ok()方法将Result转换为Option,如果成功则为Ok()值,否则为None。最后,问号运算符如果在Option为空时中止当前函数并返回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有两种数组类型。数组具有固定大小。向量可以增长和收缩。hex::decode返回一个向量,但eth_stealth_addresses库希望接收数组。.try_into()将一个值转换为另一种类型,例如将向量转换为数组。
Some(array)
}
在函数末尾返回值时,Rust不要求你使用return关键字。
#[wasm_bindgen]
pub fn wasm_generate_stealth_address(stealth_address: &str) -> Option<String> {
此函数接收一个公钥元地址,包括$V_{pub}$和$K_{pub}$。它返回隐身地址、要发布的公钥($R_{pub}$)以及一个加快识别哪些已发布地址可能属于Alice的一个字节扫描值。
扫描值是共享秘密($S = G R_{priv} V_{priv}$)的一部分。Alice可以获得这个值,检查它比检查$f(K_{pub} + G \cdot \text{hash}(S))$是否等于已发布地址要快得多。
let (address, r_pub, scan) =
generate_stealth_address(&str_to_array::<66>(stealth_address)?);
我们使用库的generate_stealth_address。
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来计算从地址提取资金的私钥($R_{priv}$)。此计算需要以下值:
- 地址($\text{地址} = f(P_{pub})$)
- 由比尔生成的公钥($R_{pub}$)
- 查看私钥($V_{priv}$)
- 花费私钥($K_{priv}$)
#[wasm_bindgen(start)]
#[wasm_bindgen(start)]指定在WASM代码初始化时执行该函数。
pub fn main() {
console_error_panic_hook::set_once();
}
此代码指定将崩溃输出发送到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
后跟堆栈跟踪。然后给比尔有效的元地址,并给Alice无效的地址或无效的公钥。你将看到此错误:
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编写,并由Vite提供服务。你可以通过本教程了解它们。这里不需要WAGMI,因为我们不直接与区块链或钱包交互。
用户界面中唯一不明显的部分是WASM连接。以下是其工作原理。
vite.config.js
此文件包含Vite配置。
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()],
})
App.jsx
此文件是应用程序的主要组件。它是一个容器,包含两个组件:Alice和Bill,分别是这些用户的用户界面。与WASM相关的部分是初始化代码。
import init from './rust-wasm/pkg/rust_wasm.js'
当我们使用wasm-pack时,它会创建两个文件:包含实际代码的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()
}, []
)
useEffectHook允许你指定一个在状态变量更改时执行的函数。此处状态变量列表为空([]),因此此函数仅在页面加载时执行一次。
效果函数必须立即返回。要使用异步代码,例如WASM init(必须加载.wasm文件,因此需要时间),我们定义一个内部async函数并运行它,不带await。
Bill.jsx
这是比尔的用户界面。它有一个操作,即根据Alice提供的隐身元地址创建地址。
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中的代码类似,只是Alice有两个操作:
- 生成元地址
- 获取由比尔发布的地址的私钥
结论
隐身地址并非万能药;它们必须正确使用。但正确使用时,它们可以在公共区块链上实现隐私保护。
本教程对你有帮助吗?
- 原文链接: ethereum.org/developers/...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~


