Using Stealth Addresses
You're Bill. For reasons we won't go into, you want to donate to the "Alice for Queen of the World" campaign and have Alice know you donated so she'll reward you if she wins. Unfortunately, her victory is not guaranteed. There is a competing campaign, "Carol for Empress of the Solar System". If Carol wins, and she finds out you donated to Alice, you'll be in trouble. So you can't just transfer 200 ETH from your account to Alice's.
ERC-5564 has the solution. This ERC explains how to use stealth addresses for anonymous transfer.
Warning: The cryptography behind stealth addresses is, as far as we know, sound. However, there are potential side-channel attacks. Below, you will see what you can do to reduce this risk.
How stealth addresses work
This article will attempt to explain stealth addresses in two ways. The first is how to use them. This part is sufficient to understand the rest of the article. Then, there is an explanation of the mathematics behind it. If you are interested in cryptography, read this part as well.
The simple version (how to use stealth addresses)
Alice creates two private keys and publishes the corresponding public keys (which can be combined into a single double-length meta-address). Bill also creates a private key and publishes the corresponding public key.
Using one party's public key and the other's private key, you can derive a shared secret known only to Alice and Bill (it can't be derived from the public keys alone). Using this shared secret, Bill obtains the stealth address and can send assets to it.
Alice also gets the address from the shared secret, but because she knows the private keys to the public keys she published, she can also get the private key that lets her withdraw from that address.
The mathematics (why stealth addresses work like this)
Standard stealth addresses use elliptic-curve cryptography (ECC) to get better performance with fewer key bits, while still keeping the same level of security. But for the most part we can ignore that and pretend we are using regular arithmetic.
There is a number everybody knows, G. You can multiply by G. But because of the nature of ECC, it is practically impossible to divide by G. The way public key cryptography generally works in Ethereum is that you can use a private key, Ppriv, to sign transactions that are then verified by a public key, Ppub = GPpriv.
Alice creates two private keys, Kpriv and Vpriv. Kpriv will be used to spend money out of the stealth address, and Vpriv to view the addresses that belong to Alice. Alice then publishes the public keys: Kpub = GKpriv and Vpub = GVpriv
Bill creates a third private key, Rpriv, and publishes Rpub = GRpriv to a central registry (Bill could also have sent it to Alice, but we assume Carol is listening).
Bill calculates RprivVpub = GRprivVpriv, which he expects Alice also to know (explained below). This value is called S, the shared secret. This gives Bill a public key, Ppub = Kpub+G*hash(S). From this public key, he can calculate an address and send whatever resources he wants to it. In the future, if Alice wins, Bill can tell her Rpriv to prove the resources came from him.
Alice calculates RpubVpriv = GRprivVpriv. This gives her the same shared secret, S. Because she knows the private key, Kpriv, she can calculate Ppriv = Kpriv+hash(S). This key lets her access assets in the address that results from Ppub = GPpriv = GKpriv+G*hash(S) = Kpub+G*hash(S).
We have a separate viewing key to allow Alice to subcontract to Dave's World Domination Campaign Services. Alice is willing to let Dave know the public addresses and inform her when more money is available, but she doesn't want him spending her campaign money.
Because viewing and spending use separate keys, Alice can give Dave Vpriv. Then Dave can calculate S = RpubVpriv = GRprivVpriv and that way get the public keys (Ppub = Kpub+G*hash(S)). But without Kpriv Dave cannot get the private key.
To summarize, these are the values known by the different participants.
| Alice | Published | Bill | Dave |
|---|---|---|---|
| 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) | - | - | - |
When stealth addresses go wrong
There are no secrets on the blockchain. While stealth addresses can provide you with privacy, that privacy is susceptible to traffic analysis. To pick a trivial example, imagine that Bill funds an address and immediately sends a transaction to publish an Rpub value. Without Alice's Vpriv, we can't be sure that this is a stealth address, but that is the way to bet. Then, we see another transaction that transfers all the ETH from that address to Alice's campaign fund address. We may not be able to prove it, but it's likely that Bill just donated to Alice's campaign. Carol would certainly think so.
It is easy for Bill to separate the publication of Rpub from the funding of the stealth address (do them at different times, from different addresses). However, that is insufficient. The pattern Carol looks for is that Bill funds an address, and then Alice's campaign fund withdraws from it.
One solution is for Alice's campaign not to withdraw the money directly, but use it to pay a third party. If Alice's campaign sends 10 ETH to Dave's World Domination Campaign Services, Carol only knows that Bill donated to one of Dave's customers. If Dave has enough customers, Carol would not be able to know if Bill donated to Alice who competes with her, or to Adam, Albert, or Abigail that Carol doesn't care about. Alice can include a hashed value with the payment, and then provide Dave the preimage, to prove that it was her donation. Alternatively, as noted above, if Alice gives Dave her Vpriv, he already knows who the payment came from.
The main problem with this solution is that it requires Alice to care about secrecy when that secrecy benefits Bill. Alice may want to maintain her reputation so Bill's friend Bob will also donate to her. But it's also possible that she wouldn't mind exposing Bill, because then he'll be afraid of what will happen if Carol wins. Bill might end up providing Alice even more support.
Using multiple stealth layers
Instead of relying on Alice to preserve Bill's privacy, Bill can do it himself. He can generate multiple meta-addresses for fictional people, Bob and Bella. Bill then sends ETH to Bob, and "Bob" (who is actually Bill) sends it to Bella. "Bella" (also Bill) sends it to Alice.
Carol can still do traffic analysis and see the Bill-to-Bob-to-Bella-to-Alice pipeline. However, if "Bob" and "Bella" also use ETH for other purposes, it won't appear that Bill transferred anything to Alice, even if Alice immediately withdraws from the stealth address to her known campaign address.
Writing a stealth-address application
This article explains a stealth-address application available on GitHub.
Tools
There is a typescript stealth address library we could use. However, cryptographic operations can be CPU-intensive. I prefer to implement them in a compiled language, such as Rust, and use WASM to run the code in the browser.
We are going to use Vite and React. These are industry-standard tools; if you are not familiar with them, you can use this tutorial. To use Vite, we need Node.
See stealth addresses in action
-
Clone the GitHub repository.
1git clone https://github.com/qbzzt/251022-stealth-addresses.git2cd 251022-stealth-addresses -
Install prerequisites and compile the Rust code.
1cd src/rust-wasm2rustup target add wasm32-unknown-unknown3cargo install wasm-pack4wasm-pack build --target web -
Start the web server.
1cd ../..2npm install3npm run dev -
Browse to the application. This application page has two frames: one for Alice's user interface and the other for Bill's. The two frames do not communicate; they are only on the same page for convenience.
-
As Alice, click Generate a Stealth Meta-Address. This will display the new stealth address and the corresponding private keys. Copy the stealth meta-address to the clipboard.
-
As Bill, paste the new stealth meta-address and click Generate an address. This gives you the address to fund for Alice.
-
Copy the address and Bill's public key and paste them in the "Private key for address generated by Bill" area of Alice's user interface. Once those fields are filled in, you will see the private key to access assets at that address.
-
You can use an online calculator to ensure the private key corresponds to the address.
How the program works
The WASM component
The source code that compiles into WASM is written in Rust. You can see it in src/rust_wasm/src/lib.rs. This code is primarily an interface between the JavaScript code and the eth-stealth-addresses library.
Cargo.toml
Cargo.toml in Rust is analogous to package.json in JavaScript. It contains package information, dependency declarations, etc.
1[package]2name = "rust-wasm"3version = "0.1.0"4edition = "2024"56[dependencies]7eth-stealth-addresses = "0.1.0"8hex = "0.4.3"9wasm-bindgen = "0.2.104"10getrandom = { version = "0.2", features = ["js"] }显示全部The getrandom package needs to generate random values. That cannot be done by pure algorithmic means; it requires access to a physical process as a source of entropy. This definition specifies that we'll get that entropy by asking the browser we're running in.
1console_error_panic_hook = "0.1.7"This library gives us more meaningful error messages when the WASM code panics and cannot continue.
1[lib]2crate-type = ["cdylib", "rlib"]The output type required to produce WASM code.
lib.rs
This is the actual Rust code.
1use wasm_bindgen::prelude::*;The definitions to create a WASM package out of Rust. They are documented here.
1use eth_stealth_addresses::{2 generate_stealth_meta_address,3 generate_stealth_address,4 compute_stealth_key5};The functions we need from the eth-stealth-addresses library.
1use hex::{decode,encode};Rust typically uses byte arrays ([u8; <size>]) for values. But in JavaScript, we typically use hexadecimal strings. The hex library translates for us from one representation to the other.
1#[wasm_bindgen]Generate WASM bindings to be able to call this function from JavaScript.
1pub fn wasm_generate_stealth_meta_address() -> String {The easiest way to return an object with multiple fields is to return a JSON string.
1 let (address, spend_private_key, view_private_key) = 2 generate_stealth_meta_address();The generate_stealth_meta_address returns three fields:
- The meta-address (Kpub and Vpub)
- The viewing private key (Vpriv)
- The spending private key (Kpriv)
The tuple syntax lets us separate those values again.
1 format!("{{\"address\":\"{}\",\"view_private_key\":\"{}\",\"spend_private_key\":\"{}\"}}",2 encode(address),3 encode(view_private_key),4 encode(spend_private_key)5 )6}Use the format! macro to generate the JSON-encoded string. Use hex::encode to change the arrays to hex strings.
1fn str_to_array<const N: usize>(s: &str) -> Option<[u8; N]> {This function turns a hex string (provided by JavaScript) into a byte array. We use it to parse values provided by the JavaScript code. This function is complicated because of how Rust handles arrays and vectors.
The <const N: usize> expression is called a generic. N is a parameter that controls the length of the returned array. The function is actually called str_to_array::<n>, where n is the array length.
The return value is Option<[u8; N]>, which means the returned array is optional. This is a typical pattern in Rust for functions that may fail.
For example, if we call str_to_array::10("bad060a7"), the function is supposed to return a ten-value array, but the input is only four bytes. The function needs to fail, and it does so by returning None. The return value for str_to_array::4("bad060a7") would be Some<[0xba, 0xd0, 0x60, 0xa7]>.
1 // decode returns Result<Vec<u8>, _>2 let vec = decode(s).ok()?;The hex::decode function return a Result<Vec<u8>, FromHexError>. The Result type can contain either a successfult result (Ok(value)) or an error (Err(error)).
The .ok() method turns the Result into an Option, whose value is either the Ok() value if successful or None if not. Finally, the question mark operator aborts the current functions and returns a None if the Option is empty. Otherwise, it unwraps the value and returns that (in this case, to assign a value to vec).
This looks like a strangely convoluted method to handle errors, but Result and Option ensure that all errors are handled, one way or another.
1 if vec.len() != N { return None; }If the number of bytes is incorrect, that's a failure, and we return None.
1 // try_into consumes vec and attempts to make [u8; N]2 let array: [u8; N] = vec.try_into().ok()?;Rust has two array types. Arrays have a fixed size. Vectors can grow and shrink. hex::decode returns a vector, but the eth_stealth_addresses library wants to receive arrays. .try_into() converts a value into another type, for example, a vector into an array.
1 Some(array)2}Rust does not require you to use the return keyword when returning a value at the end of a function.
1#[wasm_bindgen]2pub fn wasm_generate_stealth_address(stealth_address: &str) -> Option<String> {This function receives a public meta-address, which includes both Vpub and Kpub. It returns the stealth address, the public key to publish (Rpub), and a one-byte scan value that speeds up the identification of which published addresses may belong to Alice.
The scan value is part of the shared secret (S = GRprivVpriv). This value is available to Alice, and checking it is much faster than checking whether f(Kpub+G*hash(S)) equals the published address.
1 let (address, r_pub, scan) = 2 generate_stealth_address(&str_to_array::<66>(stealth_address)?);We use the library's generate_stealth_address.
1 format!("{{\"address\":\"{}\",\"rPub\":\"{}\",\"scan\":\"{}\"}}",2 encode(address),3 encode(r_pub),4 encode(&[scan])5 ).into()6}Prepare the JSON-encoded output string.
1#[wasm_bindgen]2pub fn wasm_compute_stealth_key(3 address: &str, 4 bill_pub_key: &str, 5 view_private_key: &str,6 spend_private_key: &str 7) -> Option<String> {8 .9 .10 .11}显示全部This function uses the library's compute_stealth_key to calculate the private key to withdraw from the address (Rpriv). This calculation requires these values:
- The address (Address=f(Ppub))
- The public key generated by Bill (Rpub)
- The view private key (Vpriv)
- The spend private key (Kpriv)
1#[wasm_bindgen(start)]#[wasm_bindgen(start)] specifies that the function is executed when the WASM code is initialized.
1pub fn main() {2 console_error_panic_hook::set_once();3}This code specifies that panic output be sent to the JavaScript console. To see it in action, use the application and give Bill an invalid meta-address (just change one hexadecimal digit). You will see this error in the JavaScript console:
1rust_wasm.js:236 panicked at /home/ori/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/subtle-2.6.1/src/lib.rs:701:9:2assertion `left == right` failed3 left: 04 right: 1Followed by a stack trace. Then give Bill the valid meta-address, and give Alice either an invalid address or an invalid public key. You will see this error:
1rust_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:2keys do not generate stealth addressAgain, followed by a stack trace.
The User interface
The user interface is written using React and served by Vite. You can learn about them using this tutorial. There is no need for WAGMI here because we do not interact directly with a blockchain or a wallet.
The only non-obvious part of the user interface is WASM connectivity. Here is how it works.
vite.config.js
This fine contains the Vite configuration.
1import { defineConfig } from 'vite'2import react from '@vitejs/plugin-react'3import wasm from "vite-plugin-wasm";45// https://vite.dev/config/6export default defineConfig({7 plugins: [react(), wasm()],8})We need two Vite plugins: react and wasm.
App.jsx
This file is the main component of the application. It is a container that includes two components: Alice and Bill, the user interfaces for those users. The relevant part for WASM is the initialization code.
1import init from './rust-wasm/pkg/rust_wasm.js'When we use wasm-pack, it creaates two files we use here: a wasm file with the actual code (here, src/rust-wasm/pkg/rust_wasm_bg.wasm) and a JavaScript file with the definitions to use it (here, src/rust_wasm/pkg/rust_wasm.js). The default export of that JavaScript file is code that needs to run to initiate WASM.
1function App() {2 .3 .4 .5 useEffect(() => {6 const loadWasm = async () => {7 try {8 await init();9 setWasmReady(true)10 } catch (err) {11 console.error('Error loading wasm:', err)12 alert("Wasm error: " + err)13 }14 }1516 loadWasm()17 }, []18 )显示全部The useEffect hook lets you specify a function that gets executed when state variables change. Here, the list of state variables is empty ([]), so this function is executed only once when the page loads.
The effect function has to return immediately. To use asynchronous code, such as the WASM init (which has to load the .wasm file and therefore takes time) we define an internal async function and run it without an await.
Bill.jsx
This is the user interface for Bill. It has a single action, creating an address based on the stealth meta-address provided by Alice.
1import { wasm_generate_stealth_address } from './rust-wasm/pkg/rust_wasm.js'In addition to the default export, the JavaScript code generated by wasm-pack exports a function for every function in the WASM code.
1 <button onClick={() => {2 setPublicAddress(JSON.parse(wasm_generate_stealth_address(stealthMetaAddress)))3 }}>To call WASM functions, we just call the function exported by the JavaScript file created by wasm-pack.
Alice.jsx
The code in Alice.jsx is analogous, except that Alice has two actions:
- Generate a meta-address
- Get the private key for an address published by Bill
Conclusion
Stealth addresses are not panacea; they have to be used correctly. But when used correctly, they can enable privacy on a public blockchain.