PQ anonymity for Stealth Address Protocol

PQ SAP - PQ Anonymity

Thanks Hy, @mmjahanara, @Pierre, @winderica, and kassandra for useful discussion and feedback.

TLDR;

This post is the 2nd post of a series of posts on Stealth Address Protocol and its Post-quantum upgrade. See Post Quantum Stealth Address Protocol for the 1st post.

Later post:

Scheme 6 PQ SAP - PQ Spending

A Stealth Address Protocol allows:

  • a receiver to register a public Stealth Meta Address
  • a sender to derive a one-time Stealth Address for the receiver using the Stealth Meta Address and an ephemeral secret (also a nonce)
  • payments from a sender to a receiver, under different one time Stealth Addresses (meaning different nonces) of the same Stealth Meta Address, are unlinkable
  • sender anonymity is not-guaranteed (as a current property)

The (current) components often coupled with the Stealth Address Protocol are:

  • Stealth Meta Address registry where receivers can register their public keys and Annoucement registry where senders can announce their stealth payments to receivers
  • nonce manager on sender’s side to prevent re-using the same one-time Stealth Address twice or more
  • stealth payment discovery or scanning service for stealth payments, for this purpose, we usually rely on Dual Key Stealth Address Protocol, which separates the spending key from the viewing key
 RECEIVER (Bob)                          SENDER (Alice)
 k_view, k_spend                         nonce mgr: fresh r, never reuse
 M = (P_view, P_spend)                        |
      |                                       |
      | register M                            | lookup M
      v                                       v
 [META ADDRESS REGISTRY] ---------------------+
                                              |  s    = KA(r, P_view)
                                              |  P_st = P_spend + H(s)G
                                              |  R    = pub(r), vtag
                                              |
 [LEDGER]              value -> P_st  <-------+
 [ANNOUNCEMENT REGISTRY]  (R, P_st, vtag) <---+
      |
      | Bob (or view-delegate) scan: s' = KA(k_view, R); vtag? P_spend+H(s')G == P_st?
      v
 Bob spends with k_spend + H(s')

 unlinkable : P_st from same M, different r        [ HOLDS ]
 sender     : Alice's account funds + announces    [ EXPOSED ]
 keys       : k_view = detect (delegatable), k_spend = spend

A quantum attacker, observing the permanently-on-chain ERC6538Registry and ERC5564Announcer, will capture R and (K,V), and all the stealth payments to a stealth address P, and can recover r from R, k from K, and v from V, and be effectively the scanner with v and the receiver with k and have full viewing and spending access to the stealth payments.

We focus on upgrading the two core components of an SAP, namely the (1/2)-ECDH and the ECDSA, resulting in a schemeId = 3, 5, and 6 SAP, and interpret the implications to existing components.

Replacing ECDH with ML-KEM aims to protect payment discovery and recipient unlinkability against quantum attackers. Replacing ECDSA aims to protect spending authorization. Schemes 2-5 retain ECDSA and therefore do not provide both properties.

In this first extension of ERC-5564, we attempt to replace the ECDH with a PQ ML-KEM, resulting in SAPs schemeId = 2-5.

We have an accompanied repo for scheme 3 which is the best first step to PQ SAP upgrade

Recalling ML-KEM

We adopt FIPS 203 for ML-KEM Module-Lattice-Based Key-Encapsulation Mechanism Standard, published 13 August 2024.

A KEM (Key-Encapsulation Mechanism) consists of three algorithms and a collection of parameter sets. The three algorithms are:

  • A probabilistic key generation algorithm denoted by KeyGen
  • A probabilistic “encapsulation” algorithm denoted by Encaps
  • A deterministic “decapsulation” algorithm denoted by Decaps
Alice                          :                   Bob
                               :
+------------------+           :
|      KeyGen      |           :
+------------------+           :
     |       |                 :
     |       +-----------------:--------------------+
     v                         :                    v
+------------------+           :           +------------------+
| decapsulation key|           :           | encapsulation key|
+------------------+           :           +------------------+
     |                         :                    |
     v                         :                    v
+------------------+    +------:-----+     +------------------+
|      Decaps      |<---| ciphertext |<----|      Encaps      |
+------------------+    +------:-----+     +------------------+
     |                         :                    |
     v                         :                    v
+------------------+           :           +------------------+
| Alice's copy of  |           :           |  Bob's copy of   |
| the shared secret|           :           | the shared secret|
+------------------+           :           +------------------+
                               :
         r'                    :                    r

A KEM is used to establish a shared secret between two parties Alice and Bob as described above.

  • Alice first runs KeyGen to generate a public encapsulation key and a private decapsulation key
  • The other party, Bob, given Alice’s encapsulation key, runs the Encaps algorithm, can produce the shared secret key r along with its associated ciphertext, and then send the ciphertext to Alice
  • Alice can then run the Decaps algorithm with her decapsulation key and the ciphertext, to obtain r' = r of the shared secret key.

ML-KEM is with three parameter sets: ML-KEM-512, ML-KEM-768, and ML-KEM-1024, with different parameter sizes as shown below (bytes):

encapsulation key decapsulation key ciphertext shared secret key
ML-KEM-512 800 1632 768 32
ML-KEM-768 1184 2400 1088 32
ML-KEM-1024 1568 3168 1568 32

Let us first fix the syntax of ML-KEM (with \lambda being security parameter):

  • (dk,ek) \leftarrow KeyGen(\lambda)
  • (r,c) \leftarrow Encaps(ek)
  • r' \leftarrow Decaps(dk,c)

A successful ML-KEM yields r' = r. A small note is that, given c, the attacker cannot tell whether it is encapsulated with a public ek or a public ek'. This is what we call indistinguishability of keys (IK-CCA, a.k.a ANO-CCA), which turns out to be important for SAP.

Does ML-KEM offer ANO-CCA?

Intuitively and fortunately, yes. There is a dedicated analysis of Post-Quantum Anonymity of Kyber in the quantum random oracle model (QROM). However, it analyzes round-3 Kyber, so applying its results to standardized ML-KEM requires accounting for the changes in FIPS 203. As specified, the FIPS 203 transform matches the intermediate transform in Figure 6 of the analysis: it returns the first half of G on valid encapsulations and uses a separate ciphertext-dependent rejection key on invalid ciphertexts. See our attempt to a high level explanation in ML-KEM and its ANO-CCA.

Direct ML-KEM: SAP schemeId = 2

We directly replace ECDH with ML-KEM.

The Math

Let G be the generator of the Elliptic Curve group, and H(\cdot) a shared-secret-key-to-scalar cryptographic hash function:

  • the receiver randomly samples k the spending key and runs (dk,ek) \leftarrow KeyGen(\lambda) and register (K, ek), where K = kG, to the public registry as the Stealth Meta Address
  • for a one time payment, the sender (in some way) obtains (K, ek), runs (r,c) \leftarrow Encaps(ek), and compute h = H(r), set P = K + hG, sends the fund to P, and then publish c
  • the receiver, or a scanning service that the receiver entrust with the decapsulation key dk, can match the stealth payment by first decapsulating r' = Decaps(dk,c), and recompute h' = H(r'), and the receiving address P' = K + h'G, and test whether P =? P'
  • however, only the receiver, who has k (not even the scanning service who only has dk), can access the fund, with the one-time spending key now being k + h for the public key P = kG + hG

As c is secure against a quantum attacker (attacker cannot learn r or linking c to ek due to ANO-CCA), a linkage back to the Stealth Meta Address (K = kG, ek) is prevented against the said attacker.

The extended ERC-5564

We can extend ERC-5564 as follows:

  • replacing viewing key pair (v,V) with ML-KEM key pair (ek,dk), and modify the algorithms per the above math, now st:eth:0x<spendingPubKey><ek>
  • we can CHOOSE to keep the NAIVE view-tag h^* which is of size 1 byte extracted as the most significant byte from h, this allows checking h^* =? H(r')[0] after recovering r' (instead of another EC scalar multiplication, and EC addition and a hash, albeit this is of a different proportion w.r.t schemeId-1), WITH SOME ANONYMITY RISK (see below).

ERC5564Announcer does not need to be modified except for perhaps a naming change, to emit respective events to announce when something is sent to a stealth address (Announcement(uint256 indexed schemeId, address indexed stealthAddress, address indexed caller, bytes mlKEMciphertext, bytes metadata)). However, with ML-KEM-768, the mlKEMciphertext is 1088 bytes.

ERC6538Registry which centralizes the registration and retrieval of public stealth meta addresses can keep current form (mapping(address registrant => mapping(uint256 schemeId => bytes))). However, with ML-KEM-768, the encapsulation key size is 1184 bytes, the bytes part will be increased by 1151 bytes.

The naive ViewTag vs CRQC

Recalling that

  • P = K + hG = (k+h)G
  • h^* = h[0]

a CRQC that knows the derived public key P can:

  • recover p=\log_G(P);
  • recover k_i=\log_G(K_i) for each candidate public spending key K_i in the registry;
  • compute h_i=(p-k_i)\bmod n and test whether h_i[0]=h^*.

The correct recipient passes while an independently generated wrong candidate passes with probability approximately 1/256. This gives a recipient-linkage filter without breaking ML-KEM. The attack requires P, which becomes recoverable from an ordinary ECDSA spend. We can prevent this direct test by deriving the tweak and view tag independently from the shared secret:

  • h=H(DS1 \parallel r)
  • h^*=H(DS2 \parallel r)[0]

The sender knows r from encapsulation, and the receiver or scanner obtains it through decapsulation. Recovering h through quantum discrete logarithms does not directly reveal the independently derived tag. Thus, the tag’s privacy depends on the protected shared secret r, without requiring the sender to know dk.

This attack is applicable to all ECDSA-based SAPs attempting to upgrade to PQ anonymity via ML-KEM (2-5 in this post).

Retrospective to schemeId = 1

  • The spend cost (gas cost) is the same
  • The forward secrecy issue remains, whoever holds dk can link the stealth payment back to the Stealth Meta Address
  • We need a full decapsulation even if we keep the view-tag.
  • the Announcement of ERC5564Announcer increase roughly by 33x, but on the other hand, this should deter DDoS attack
  • the Stealth Meta Address in ERC6538Registry increase roughly by 18x
  • ML-KEM size makes PIR and OMR worse

Hybrid-Combiner: SAP schemeId = 3

While it is theoretically ideal that scheme 2 where we replace (1/2)-ECDH with ML-KEM is sufficient for post-quantum anonymity, in practice, we usually rely on a hybrid combiner of both ECDH and ML-KEM to hedge ML-KEM security during transitional period (i.e. during migration and BEFORE CRQC arrives), hence we propose schemeId 3 for practical usage. Scheme 3 is the combination of scheme 1 and scheme 2, concretely:

  • Stealth Meta Address is (K,V,ek)
  • Sender shares two secrets with the receiver: r_{ec} using V (as in scheme 1), and r_{pq} using ek (as in scheme 2)
  • We adopt a domain-separated hybrid combiner to combine r_{ec} and r_{pq}
r = SHA3-256(DS || r_ec || r_pq || epk || ct || viewing_pk_ec || ek)

Security Considerations

As the post-quantum anonymity relies entirely on ML-KEM ciphertext, AT LEAST ONE decapsulation is necessary for security, hence, “optimization” such as “let’s make viewtag the 1st byte of hash of r_{ec} to have the same filter cost of scheme 1” is yet another catastrophic optimization.

One may also consider “reusing the scheme 1 meta address as it now seems scheme 3 will work as we append an ek to the end of the scheme 1 address”. Doing this will essentially drags along the history of scheme 1, which is considered to be broken when CRQC arrives.

Pair-wise-KEM-SAP schemeId 4,5

We can extend schemeId-2 to schemeId = 4 with pair-wise hybrid encryption (and similarly schemeId-3 to schemeId = 5), by leveraging the shared secret r to derive a new nonce for subsequent transactions of the same sender-receiver pair. This will remove the mlKEMciphertext (of scheme 2 and 3) and replace the original ephemeralPubKey with a new ephemeralPubKey that is even smaller than that of schemeId-1.

For the sake of readability, we only describe scheme 4 (the only-ML-KEM version).

The Math

Let G be the generator of the Elliptic Curve group, and H(\cdot) a bytes-to-scalar cryptographic hash function:

  • the receiver randomly samples k the spending key and runs (dk,ek) \leftarrow KeyGen(\lambda) and register (K, ek), where K = kG, to the public registry as the Stealth Meta Address
  • for a stealth pairwise-key-exchange the sender (in some way) obtains (K, ek), runs (r,c) \leftarrow Encaps(ek), and compute h = H(r), set P = K + hG, sends 0 to P, and then publish c
  • the receiver, or a scanning service that the receiver entrust with the decapsulation key dk, can match the stealth pairwise-key-exchange by first decapsulating r' = Decaps(dk,c), and recompute h' = H(r'), and the receiving address P' = K + h'G, and test whether P =? P'
  • for a one time payment, the sender who has already shared a pairwise-key r with the receiver, randomly samples \tilde{r}, and compute \tilde{h} = H(r||\tilde{r}), set \tilde{P} = K + \tilde{h}G, sends some fund to \tilde{P}, and then publish \tilde{r}
  • the receiver, or a scanning service that the receiver entrust with the already exchanged pairwise-key r, can match the stealth payment by recomputing h'' = H(r||\tilde{r}), and the receiving address P'' = K + h''G, and test whether P'' =? \tilde{P}
  • however, only the receiver, who has k (not even the scanning service who only has both dk and r), can access the fund, with the one-time spending key now being k + \tilde{h} for the public key \tilde{P} = kG + \tilde{h}G

The Standards

We can extend ERC-5564 as follows:

  • replacing viewing key pair (v,V) with ML-KEM key pair (ek,dk), and modify the algorithms per the above math, now st:eth:0x<spendingPubKey><ek>
  • we can keep view-tag h^* and give the same treatment to \tilde{h}^* of size 1 byte extracted as the most significant byte from h and \tilde{h}, this allows checking h^* =? H(r')[0] and \tilde{h}^* =? H(r||\tilde{r})[0] after recovering r' and \tilde{r} (instead of another EC scalar multiplication, and EC addition and a hash, albeit this is of a different proportion w.r.t schemeId-1).

Notice that naive viewtag derivation should not be used and the same treatment for viewtag above should be applied here to REMOVE ANONYMITY RISK, for both first contact and actual payment messages.

ERC5564Announcer can be used for schemeId-2 (or 3) for stealth key-exchange and schemeId-4 (resp. 5) for stealth payment.

ERC6538Registry which centralizes the registration and retrieval of public stealth meta addresses can keep current form (mapping(address registrant => mapping(uint256 schemeId => bytes))). However, with ML-KEM-768, the encapsulation key size is 1184 bytes, the bytes part will be increased by 1151 bytes.

Retrospective to previous schemes

  • the first contact between the sender and the receiver has additional overhead of the stealth key exchange but then the actual stealth payment costs less than even scheme 1
  • r as a new key to manage and scan per sender-receiver pair (subject to short-circuit on first match), however this means that now receiver can delegate r instead of ek (and/or V), and at the same time, now forward secrecy is more fine-grained with the pairwise r
  • additionally, IF the sender makes \tilde{r} a COUNTER (per r) WITHOUT PUBLISING IT, while we still preserve randomness of \tilde{h} = H(r||\tilde{r}), receiver doesn’t need to store \tilde{r}, but only keep track of the counter, or simply scan from 0, to match its stealth payments, indeed, management of r is easier than management of all shared secrets of previous schemes

Worthy Lightweight Deviations

A stealthier SAP

Notice that the key pair of all previous schemes maintain a key K, mathematically speaking, K has the same property of V, which means, an additional secret can be shared between the sender and the receiver with the cost of ONE additional ephemeralPubKey, this opens a potential upgrade to SAP, which we call “a-stealthier-SAP”. All we need to do, applicable to all previous schemes, are the following additional steps (and some trivial modifications to the ERC-5564 which make it a new scheme that is still compatible with the existing infrastructure ERC5564Announcer and ERC6538Registry):

  • sender samples r_K
  • computes R_K = r_KG and S_K = r_KK,
  • publishes R_K in the announcement,
  • and sends fund from an address B to P_K = K + H(S_K)G
  • P = K + H(S)G is kept for matching purpose, and this goes into the stealth address field of the announcement
  • sender uses another address A to send the announcement
  • announcement and actual payment can be in different block

Concretely it gives the following:

Party Recognizes the notification Derives the actual payment address Can spend
Sender Yes Yes No
Scanner holding v Yes No, from the cryptographic inputs alone No
Receiver holding k,v Yes Yes Yes
Outside classical observer Not directly Not directly No

This effectively disconnects the stealth payment (from B to P_K) and the announcement (caller A with stealth address P for matching) and make a stealth payment not being obviously classified as a stealth payment anymore. Yet another great effect that this can give, is now Ethereum can be used to facilitate stealth payments on another blockchain, i.e. Ethereum for announcement while actual (no-one-can-tell-it-is-a-stealth) payment can happen on another chain (assuming there is always a way to modify the algorithm to accomodate the foreign chain’s addressing scheme).

IMPORTANT: the PQ version should combine S_K with r_{pq} as in schemeId 3, and we have

Observer Knows S_K? Knows r_{pq}? Can reconstruct the payment address?
Classical scanner holding dk No Yes No
Quantum outsider without dk Yes No No
Quantum scanner holding dk Yes Yes Yes

While this may have already been considered before, making this happened in the classical settings essentially double the announcement cost (2 ephemeralPubKeys to announce) compared to scheme 1, however in the post quantum settings with the addition of ML-KEM it makes this cost has become almost free which makes it even more appealing now.

Post-quantum Recoverability

This is a upgrade that any SAP (even scheme 1) can consider, if we make the key k a hash of a seed s, which is never used in any SAP operation (except during key management), combining this with the onchain announcement (which has the secret r protected by ML-KEM), we have PQ recoverability, i.e. if a quantum incident happens, the legitimate owner of the SAP meta address can prove in zk k = H(s) and use the observation of p = k + r to beat quantum adversary in the address recovery (assuming we have an enforceable, precommitted recovery policy, or an explicit assumption about future consensus intervention). Expanding this to a complete scheme is an interesting and important future work that we will consider.

4 Likes

Hey, seems like we’ve both been working on the same problem. Wanted to drop a few notes from working on pq-sap.gwei.domains and writing things down at skas.gwei.domains.

Backing research

I’m still quite new to PQ, so I heavily relied on this paper when looking into ML-KEM for discovery. Most of the work since then has been trying to reproduce things, measure them, and figure out which parts should actually belong in the proposal.

Hybrid approach

During the research I also came across X-Wing, which got me prototyping again. ML-KEM-768 + X25519 sounds like a reasonable fallback against a classical break of either component.

There are some benchmarks and notes in the repo. The extra bytes are fairly cheap; the extra curve operation during scanning is more noticeable. And keeping the shared secret secure doesn’t necessarily keep the recipient anonymous, which needs its own analysis.

Ideally I’d like to leave room for both ML-KEM and a hybrid option without creating a separate scheme ID for every combination.

Scheme ID 2

To not do everything at once, I basically came back to keeping the proposal primarily about key exchange and address derivation. Spending is still a moving part on the protocol level, and I’d rather collect more data before setting it in stone.

Your scheme 6 already goes in that direction. What I’m wondering is whether classical spending could fit there as well. If discovery stays the same and only the account’s verifier changes, does that really need another scheme ID?

Different discovery formats still need to be distinguishable, of course.

Account abstraction

The useful change for me was deriving a contract account instead of an EOA. That lets the account decide how spending works, while the discovery part only needs to know how to derive the correct destination.

The shared secret alone isn’t enough to spend, since the sender knows it too. The account still has to check a separate recipient-only secret.

I’ve also been experimenting with frame transactions: deploy the account, verify authorization, handle sponsorship, then execute the spend. Quite a few things to explore there without dragging all of them into the discovery spec.

Stealth Meta Registry

There are a few questions around ERC-6538 as well. It already supports ERC-1271, but also accepts ecrecover authorization. What happens to that path once the account moves to PQ authorization? We shouldn’t leave the old key able to replace the meta-address.

Circle’s proposed ecrecover change and EIP-8151 are interesting from that perspective.

I’d also like to keep naming services and off-chain sharing as options. They still need a secure way to authenticate and update the SMA, but I don’t think discovery should depend on one registry.

Spending alone

This is where the benchmarks got slightly depressing. Some of the paths work, but the costs are difficult to justify.

SPHINCS- C13 was a nice surprise and the cheapest verifier I tested. Unfortunately, our direct route reveals the same public key on spending, linking those accounts. Hiding it inside a ZK proof works too, but adds proving costs, and the current UltraHonk backend isn’t PQ-sound.

As you mentioned, in-mempool signature and proof aggregation could change the economics quite a bit. Another reason for me to keep spending flexible while that work develops.

k1 spending

The ML-KEM + secp256k1 spending route was also suggested by an external contributor. It’s currently documented separately, but I’ve been looking at how classical and PQ spending could fit under the same discovery scheme through different account implementations.

Curious what you think about drawing the boundary there, and whether we could share some of the implementation work

1 Like

@Skanislav Thanks for the notes, I think scheme 3 is a very straightforward and manageable upgrade while other variants have moving parts and trying to capture them in the ERC now can be over-engineering. Although scheme 6 is the ultimate form with (native) account abstraction which we should work towards.

Hey! @namnc +1 that scheme 3 is the clean near-term discovery upgrade. The part I want to add substance to is scheme 6, the (native) AA form, because that is where the classical-vs-PQ spending question actually resolves IMO.

how classical and PQ spending could fit under the same discovery scheme through different account implementations

@Skanislav

That was the core of the ML-KEM + secp256k1 route I proposed in pq-sap: let a schemeId pin down discovery only, and move spending entirely into the account. One discovery scheme (ML-KEM or scheme 3) produces the stealth point, which becomes the commitment/salt for a counterfactual account, and the account’s bytecode is what defines authorization. Same derivation input and announcement bytes, but a different account implementation:

  • a k1 account.
  • an ML-DSA-gated account.
  • a hybrid account (k1 + PQ).

Two properties fall out of this that I think answer open questions here:

  1. No schemeId proliferation: spending variety is an account-code difference, not a discovery one, so you never fork the discovery scheme or the registry format, and classical and PQ spenders share one anonymity set.
  2. The ERC-6538 authority gap closes itself: if the account implementation owns its authorization, there is no ecrecover-based replacement privilege sitting in the registry to leak once the account moves to PQ.

I have since tested this scheme 6 shape on-chain over EIP-8141 frames (on the ethrex testnet): derive the counterfactual account from the ML-KEM commitment (CREATE2 salt) and let its code carry the spend rule. This is the key point where the cost story gets concrete: k1 spending is negligible, an inline ML-DSA-44 verify is ~5M gas today (ZKNOX ETHDilithium), and EIP-8355 native precompiles would cut that by two or three orders of magnitude. So IMHO the k1 account is the bridge for now: PQ anonymity now via ML-KEM discovery, PQ authorization swapped in per-account when the verify is affordable.

This rests on treating PQ anonymity and PQ authorization as separable threat models. ML-KEM discovery defeats announcement unlinkability (the harvest-now-decrypt-later target) even while spending stays k1. A quantum adversary that also breaks k1 could spend, but by then every k1 EOA is broken, so it is not a stealth-specific regression.

One concern that sits above the crypto is that the traditional ERC-5564 announcer is a single global event stream, and the set a scanner actually has to consider is small and enumerable. I measured this in an announcer/scanning PoC I ran: e.g., over one month on Ethereum mainnet the canonical ERC-5564 announcer carried 27 announcements, and even the most-used legacy announcer (Umbra) carried 405. Those are effectively the entire anonymity sets, bounded by the announcer model itself. PQ discovery protects a given announcement from future deanonymization, but it does not enlarge a set the announcement mechanism already keeps this small.

That is also why I would resist cheapening the filter: +1 on “at least one decapsulation per announcement” being fundamental and on the viewtag being a catastrophic optimization.

@ivanmmurciaua Yes, I think we agree with each other. What I meant was that, as HNDL is an urgency, I put forward scheme 3 as an immediate upgrade with manageable effort. Then in scheme 6 it should leverage smart account and leave authorization to code: this would be your “scheme 3 but we have 3 modes”.

2 Likes

gm, data point on the anonymity-set concern above, from mainnet today: the ERC-5564 Announcer logged one announcement in the last 24 hours, and its last 10 announce transactions span 24 days.

With sets that small, linking a stealth address to its owner usually needs no cryptography at all.

Timing, how the address gets gas, and where funds go after withdrawal do the work.

So for the near term I’d put two things next to the hybrid ML-KEM scheme:

  1. Receive-side defaults in wallets: don’t fund a stealth address from a known account, don’t withdraw to a reused destination, don’t withdraw minutes after receipt.
  2. Keeping announcements cheap and standard, so more payments land in the same set.

We’re publishing a measurement of how many existing stealth addresses are linkable this week, with code, and will link it here. will push updates tmrw

1 Like

A practical note for this week’s bunker-mode discussion.

Receive side: an ERC-5564 stealth address has never signed when funds arrive, so its public key stays hidden until the first spend. That already matches the fresh-address advice.

The exposure is elsewhere:

  1. The ERC-6538 registry stores the meta-address, i.e. the spending and viewing public keys. With fast ECDLP those yield every stealth private key and link every payment, past and future.
  2. Registering exposes the registrant’s own public key, and the registrant is also the most common withdrawal destination (9,709 traced withdrawals in our mainnet scan).
  3. Each announcement puts the ephemeral public key onchain. With fast ECDLP and a known viewing key, old payments can be linked retroactively.

Until a PQ scheme lands, wallets can cut exposure today: share the meta-address offchain instead of registering it, never withdraw to the registrant, and if the meta-address was ever published, don’t hold long-term funds in stealth addresses.

Numbers and method in my bio

Thanks @namnc for the series, and @Skanislav, ivanmmurciaua and siriusthemaster for the follow-ups. I’ve been building on this problem for the last 7–8 months. SPECTER is a working ML-KEM-based SAP that runs end to end: key generation, meta-address publication, sending, on-chain announcement, scanning and claiming. We arrived independently at a construction that is essentially your scheme 2. That includes deriving the view tag separately from the tweak, which has been in our code since February. It is also the “ML-KEM discovery + k1 spending” bridge ivanmmurciaua describes, with a plain EOA as the k1 spender. Below are the concrete construction, a few implementation findings that seem relevant to the ERC, and where we fall short.

References (new account, so links aren’t allowed yet):

Live app         specterpq.com
Protocol (Rust)  github.com/pranshurastogi/SPECTER
SDK (npm, WASM)  npmjs.com/package/@specterpq/sdk
SDK source       github.com/specter-privacy/specter-sdk
Playground       github.com/specter-privacy/playground
Docs / API       docs.specterpq.com   (OpenAPI: docs.specterpq.com/openapi.yaml)

1. Keys and meta-address

  • Spending: secp256k1, (k, K = kG), with K compressed (33 B).
  • Viewing: ML-KEM-768 (FIPS 203, RustCrypto ml-kem), (dk, ek) with |ek| = 1184 B and |dk| = 2400 B.
  • Meta-address: version(1) ‖ K(33) ‖ ek(1184) = 1218 B, i.e. your st:eth:0x<spendingPubKey><ek> with a version byte in front.

2. Hashing and domain separation

Every value derived from the shared secret r goes through one SHAKE256 wrapper with a length-prefixed domain:

H_D(x, n) = SHAKE256( LE32(|D|) ‖ D ‖ x )[0..n]
Output Domain
tweak h SPECTER_STEALTH_TWEAK_V2
view tag SPECTER_VIEW_TAG_V1
metadata AEAD key SPECTER_META_ENC_KEY_V1
metadata AEAD nonce SPECTER_META_ENC_NONCE_V1

3. Sending

(r, c)  ← ML-KEM-768.Encaps(ek)                 // |c| = 1088 B, |r| = 32 B
h       = H_TWEAK(r ‖ ctr, 32)                   // ctr = 0,1,... until h ∈ [1, n)
P       = K + h·G
tag     = H_VIEWTAG(r, 32)[0]
  • Recipient spend key: p = k + h mod n. The sender knows h but not k.
  • Address formats: one P covers both chain families. EVM uses keccak256(X‖Y)[12:]. Sui uses blake2b-256(0x01 ‖ compressed(P)), where 0x01 is the secp256k1 scheme flag. So one meta-address receives on EVM chains and on Sui.
  • Why the tag is safe against a CRQC: the tag is H(DS2‖r)[0] and the tweak is H(DS1‖r‖ctr). A CRQC that recovers log_G(P) and every log_G(K_i) learns each candidate h_i, but that says nothing about the tag. So the 1/256 recipient-linkage filter from your post doesn’t apply, and the tag’s privacy rests entirely on r.

4. Announcement format

Our announcer follows the ERC-5564 shape, except that the log carries a hash of the ciphertext:

event Announcement(
    uint256 schemeId,
    address indexed stealthAddress,
    address indexed caller,
    bytes32 ephemeralKeyHash,   // keccak256(c)
    bytes   metadata
);
function announce(address stealthAddress, bytes calldata ephemeralPubKey, bytes calldata metadata) external;
function announce(uint256 schemeId, address stealthAddress, bytes calldata ephemeralPubKey, bytes calldata metadata) external;
function announceMany(address[] calldata, bytes[] calldata, bytes[] calldata) external;   // ≤ 50
  • Ciphertext lives in calldata. The 1088 B c is passed as calldata (length-checked on-chain) and only keccak256(c) goes into the log. Indexers decode c from the announce() calldata and check it against the logged hash. This avoids paying for c twice: under the standard EVM schedule, it saves about 8.7k gas of LOG data per announcement. The calldata cost of c stays.
  • Metadata (93 B): tag(1) ‖ AES-256-GCM(payment_tx_hash(32) ‖ amount(32) ‖ source_chain_id(8) ‖ reserved(4)) ‖ GCM tag(16). The key and nonce come from r under the two META domains, so each one is used exactly once. Only the tag is in the clear.
  • schemeId: a placeholder (1000) for now. We’d rather use whatever ID this series settles on.

5. Implementation findings

a) The view tag can’t prefilter ML-KEM, so don’t design storage around that. Logging only keccak256(c) invites an optimization: fetch c only for announcements whose tag matches. That’s circular, because checking the tag needs r, and r needs Decaps(dk, c). This is the same point as your “a full decapsulation even if we keep the view-tag”. So we resolve c from calldata at index time (an Envio HyperIndex indexer plus a TypeScript poller, both checking the keccak hash). Scanning then costs one Decaps plus one SHAKE256 for every announcement, and EC work only for tag matches.

b) Implicit rejection makes the tag the only cryptographic filter, so a tag match must not count as a discovery. Decaps never errors on a foreign c; it returns a pseudorandom r'. About 1/256 of foreign announcements therefore pass the tag. A scanner has to confirm a match with P' == stealthAddress (or with the metadata AEAD verifying) before reporting it. We found this in our own scanner and are tightening it. It may be worth stating explicitly in the ERC text.

c) Shared test vectors would have saved us time. At one point our SDK and our backend derived different tweaks. The result was silent: scans came back empty rather than failing. A common vector set across implementations would catch this early, and we’d contribute ours.

6. Architecture

  • Client-side: the web app does key generation, scanning, spend-key derivation and transaction signing locally through the WASM SDK. No API endpoint receives a secret key.
  • Self-sovereign recovery: a standalone script (@specterpq/sdk + viem) scans the announcer straight from an RPC: getLogs → fetch tx → decode calldata → check keccak → Decaps → tag → derive. No SPECTER infrastructure is involved.
  • Meta-address distribution: IPFS, referenced from an ENS text record specter (ipfs://CID, falling back to EIP-1577 contenthash), with no ERC-6538 registration. As siriusthemaster recommends, this keeps registration from exposing the registrant’s own account. The meta-address can still be replaced by whoever controls the name’s ECDSA key, though. So the authority gap @Skanislav raised is moved up a layer, not closed. ivanmmurciaua’s “authorization lives in the account” framing is the cleaner answer.
  • Relaying: announce() is sent by a gas-sponsored relayer that first checks the payment tx paid P the stated amount (native or ERC-20). Payments can be on Ethereum, Arbitrum Sepolia, Monad testnet or Sui, and all announcements go to one announcer on Monad. One caveat: the announced stealthAddress is the funded address, so this does not give the payment/announcement unlinkability of your “stealthier SAP”. It only keeps the payer’s account out of caller.

7. Where we fall short

  1. Encapsulation happens on our server in the web send flow today. The API runs Encaps(ek) and holds r (KEK-wrapped) until it publishes the announcement, so our backend can currently link payments to recipients. The SDK already supports local encapsulation. Moving Encaps and metadata encryption to the sender’s device is the next change, so that only the sender and the dk holder ever see r.
  2. Our announcements form a separate, tiny anonymity set. We use our own announcer, our own schemeId, and a testnet. siriusthemaster’s numbers make the cost of that fragmentation concrete, and it’s the main reason we’d like to converge on a standard scheme 3 announcement rather than keep a bespoke one.
  3. No hybrid yet. It’s ML-KEM only. Scheme 3, as @namnc frames it, is the natural next step: the combiner SHA3-256(DS ‖ r_ec ‖ r_pq ‖ epk ‖ c ‖ V ‖ ek), with the tag and tweak derived from the combined r and never from r_ec alone, under a fresh meta-address rather than an extended scheme-1 one.
  4. Spending is an EOA, and k is sampled from a CSPRNG rather than derived as H(seed). Both point toward the scheme 6 shape. One option is a counterfactual account derived from the ML-KEM commitment, as ivanmmurciaua tested, with k1 authorization now and PQ authorization later. Seed-derived k matters if we want the PQ-recoverability path, and it has to be in place before it’s needed.

Happy to go deeper on any part. Everything above is in the open repos.