Thanks to Joe, Nico, Palla, Lasse, Jan, Zac, and several others for discussion on this topic so far.
Related, but outdated and less detailed: Who is msg.sender when making private -> public function calls
This post is broken into two, because it was so massive!
This post is a deep exploration into the possible ways in which msg_sender
could be “masked” when making private->public calls.
There are some approaches which could be implemented by app developers in the community, but such approaches often result in extra kernel iterations for users. There are approaches which would need to be implemented in the Aztec protocol’s kernel circuits, but we don’t understand the implications well enough to be comfortable implementing those, yet.
There’s a huge section on so-called “Stealth Addresses”, which not only enable msg_sender
to be “masked”, but also unlock extra interesting private smart contract patterns that could be useful to developers. There are several approaches to stealth addresses, and each approach comes with tradeoffs.
After writing, we find ourselves unsure about which way to go in the long-term, so opinions would be very welcome! See Where to go from here? at the end.
Priv->Pub calls
There are a few scenarios in which private->public calls are made:
- A private function of a user’s account contract calling a public function of some app.
- At the moment, this leaks the user’s address.
- A private function of some app contract calling a public function of some other app.
- At the moment, this leaks the address of the calling app, and hence leaks details about the private component of the tx.
- Sometimes, such leakage might be acceptable, sometimes it will not be. It all depends on the apps.
- An internal call: a private function of an app making an internal call to an
internal
public function of the same app.- This privacy leakage is unavoidable.
Summary table of options for hiding msg_sender
:
Here’s a pretty table. More detail on each scheme is given in subsections below.
In this doc, the term “bookkeeping” is used to mean: the logic to privately ascribe state to the correct underlying user’s address, even if in public-land all state is being ascribed to a single VPN-like contract address.
Router | Router with batch function processing | Deploy an ephemeral caller contract | Private internal route |
msg_sender = null |
Mask msg_sender with hash |
Mask msg_sender , stealth- address- style |
|
---|---|---|---|---|---|---|---|
Requires extra kernel recursions | Yes ![]() |
Yes ![]() |
Yes ![]() |
Yes❌ | No ![]() |
No ![]() |
No ![]() |
# extra kernel recursions for N priv->pub calls | N | 1 | N | N | 0 | 0 | 0 |
Requires kernel changes | No ![]() |
Yes ![]() |
![]() |
No ![]() |
Yes ![]() |
Yes ![]() |
Yes ![]() |
Difficulty / complexity of making kernel change | - | High. It feels like quite a big increase to attack surface. | - | - | Easy / Simple | Medium | Medium- Hard |
Comment on most difficult aspects | Private bookkeeping logic | “Reset circuit” functionality for app functions (pulling target-siloed args from data bus). | Tracking the ephemeral contract data | Private bookkeeping logic | Public function access control logic (can’t rely on msg_sender- based checks). | Keeping track of the masking secrets. | Keeping track of the masking secrets. Spending notes. |
Where is extra private bookkeeping? (Tracking of addresses / secrets / ids / sessions) | Router | Router | Original caller contract | Target app - private access function | ??? Target app, through non-address identifiers passed-in as args? |
Caller contract | Caller contract |
Who bears the complexity? | Router contract dev | Router contract dev & Aztec Labs (kernel circuits) |
Calling contract app devs | Target app devs | Target app devs | Calling contract app devs. Aztec Labs (kernel, pxe, aztec_nr) |
Calling contract app devs. Aztec Labs (kernel, pxe, aztec_nr) |
Is there a distinction btw a private vs public msg_sender of a public fn?This impacts ease of use of writing a pub fn. |
No ![]() |
No ![]() |
No ![]() |
Maybe, in the sense that all private callers would be this_ address() . |
Yes. A private msg_sender is mutated to null . |
Kind of. msg.sender from priv wouldn’t be a valid address. | No ![]() |
Can someone create a private note or message for this address? | ![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
With this solution, can a user create a partial note and pass it to public-land? | ![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
Extra constraints when calling priv->pub | +N kernel recursions. +N router fn constraints. |
+1 kernel recursions. +1 router fn constraints. |
+N kernel recursions. +N router-style fn constraints. |
+N kernel recursions. +N router-style fn constraints. |
N gates | N * 75 gates (Assuming 75-gate poseidon2) |
N * 700 (See section below) |
Systemic point of failure?[1] | Yes ![]() |
Yes ![]() |
No | No | Not really | Not really | Not really |
[1] Paticularly scary if such infrastructure (like a router) is built externally and without rigorous quality assurance. If the thing is a kernel change, we can probably get more comfortable with it (hence “Not really”), because obviously kernels are a systemic point of failure.
Here are some options:
Solve at the app level
Let apps figure it out through extra layers of indirection. A few approaches are explored here:
Router contract
Make all calls through a router contract, which manages the underlying callers.
The idea of a “router contract” is basically a VPN. A user of an app wants to make a public call. But instead of calling directly, they route the call through the “router contract”.
The router contract does its own internal bookkeeping. It makes a record of who called it (the msg_sender), and maybe it also tracks some kind of “session id” (tx id or defi-interaction id or whatever you want to call it). Then the router contract makes the public call.
The receiving public function sees the msg_sender
as the router contract, and so will associate any public state with the router contract’s address. This means the router contract will look like some giant public whale to observers, and it will need its own internal private bookkeeping system to keep track of who actually owns the states that it is publicly associated with.
Good:
- No enshrinement of new stuff.
Bad:
- Possibly have to rely on an external team to build it?
- An extra kernel recursion for every enqueued public call.
- The router contract itself might be complex to build, and with extra constraints to track its own internal state.
- Systemic point of failure. Suppose most of the network uses one router contract: it had better work correctly, or the majority of apps on Aztec could break.
- Notes cannot be created for this msg_sender.
See the table at the top for a more exhaustive summary of good and bad.
Router contract with batch function processing
You know the kernel reset circuits? You know how multiple functions can enqueue requests to the databus, and once the queue is sufficiently large, a kernel reset circuit can pull multiple functions’ worth of data from the queue and process those requests?
Well, we could give app functions that same functionality (as the reset circuit). It would reduce the number of kernel iterations, because only one instance of the target function would need to be executed.
Multiple functions would make calls to Foo.foo()
; their arguments would all get pushed to the databus; and Foo.foo
would eventually pull all of the arguments at once from the databus.
We were previously thinking about adding such functionality to enable constrained tagging requests from all functions in the call stack to be executed once, by a single tagging contract function.
This would be a complex addition to the protocol.
Good:
- No enshrinement of new stuff.
- Only 1 extra kernel iteration per tx
Bad:
- Possibly have to rely on an external team to build the router?
- Complex “batch processing” addition to the protocol.
- Still requires that 1 extra kernel iteration.
- The router contract itself might be complex to build, and with extra constraints to track its own internal state.
- Systemic point of failure. Suppose most of the network uses one router contract: it had better work correctly, or the majority of apps on Aztec could break.
See the table at the top for a more exhaustive summary of good and bad.
Deploy an ephemeral caller contract
The calling contract (e.g. some app, or a user’s account contract) “deploys” an “ephemeral” private-function-only contract each time they want to make a public call, and routes the call through that.
Recall: “deploying” an instance of a private-function-only contract doesn’t actually require a call to the “Contract Instance Deployer” to deploy anything, so this does not incur a kernel iteration to deploy, or $-costs to broadcast anything. The functions and address of the instance can just be generated locally, and then the functions can be called and executed locally. The private kernel will happily execute those functions, without doing any merkle tree membership checks on the existence of the contract instance in the nullifier tree (such checks are only required for public functions).
Good:
- No enshrinement of new stuff.
- The user/app controls its own mini router contract.
- No deployment cost for private-function-only contracts.
Bad:
- An extra kernel recursion for every enqueued public call.
- Extra complexity for all app developers to think about when making a priv->pub call, vs a universal router contract.
See the table at the top for a more exhaustive summary of good and bad.
Apps provide a private, internal
route to all their public functions.
Create a smart contract standard, where for every public function #[public] fn foo()
, a private counterpart #[private] fn foo__private_access
is provided, which simply forwards the call to foo
. This route enables msg_sender
to always be that of the target public function’s contract.
Of course, foo__private_access
would need to do similar bookkeeping to the above two options (the router and the ephemeral contracts).
Good:
Bad:
- Similar cons to the router contract and the ephemeral contract ideas:
- An extra kernel recursion for every call to the public function.
- The private function might be complex to build, and with extra constraints to track its own internal state.
- Bloats every contract.
- A weird paradigm for doing access control of public state in the public function
foo
, in a similar way tomsg_sender = null
(described below), since many timesmsg_sender
will bethis_address
. - Pushes bookkeeping complexity to every contract with a public function.
See the table at the top for a more exhaustive summary of good and bad.
This approach is effectively similar to the “msg_sender = null
” approach explained below.
msg_sender = null
Have msg_sender = null
for all private->public calls. The private kernel would mutate all msg_sender
values to some “null” value for all priv->pub calls.
A weird consequence of doing this would be that when designing a public function, an app developer wouldn’t be able to blindly copy “traditional solidity” patterns for function access control, which often perform checks against msg_sender
. Devs would need to be mindful of this edge case: that sometimes (often, in fact) msg_sender would be “null”. When storing state against an address, lots of state would end up being stored against this “null” address. A common pattern in Solidity is for the “zero address” to be a burn address, which means the “zero address” is actually a gazillionaire, with loads of tokens, so null = 0
might be too dangerous a choice for null
.
Having said all that, if null
is chosen to be a valid Aztec address, perhaps from the pov of the target public function, this paradigm works as seemlessly as the above suggestions of a Router Contract or a private-internal route to all public functions. Both of those approaches also bundle a huge number of users under a single msg_sender
address.
Good:
- Simple protocol.
- No extra kernel iterations.
Bad:
- Weird paradigm shift, maybe?
- Difficult for devs to do access control in public functions, maybe?
See the table at the top for a more exhaustive summary of good and bad.
Mask msg_sender
Have the kernel circuit mutate msg_sender
in a universal way.
Two possible approaches:
masked_msg_sender = poseidon2(msg_sender, masking_secret);
- Compute a stealth address (more on this below).
Mask msg_sender through hashing
masked_msg_sender = poseidon2(msg_sender, masking_secret);
Good:
- Simple.
- No extra kernel iterations.
Bad:
- Fiddly to keep track of every
masking_secret
. - Possibly confusing to devs, if msg_sender is being mutated.
- A hashed address is no-longer a valid address, because its preimage is not that of an address. Therefore, it does not represent an underlying account contract, and has no keys.
See the table at the top for a more exhaustive summary of good and bad.
Mask msg_sender with Stealth-Address-style derivation
:::danger
This section needs a cryptographic proof, before we do anything with the idea.
:::
The hashing computation in the section immediately above yeilds a field element which is not a valid Aztec address.
Can we get a valid masked address?
Yes, the Kernel can derive a stealth address from msg_sender
and some randomness.
Tl;dr:
masked_msg_sender = (msg_sender.to_address_point() + h(S) * G).x
where, generally for stealth addresses created by someone else on your behalf, S
is a shared secret.
Constraint-wise, this is:
msg_sender.to_address_point()
: 4h(S)
: ~75+ h(S) * G
: ~578
So ~700 gates to mask an address in this way.
(See the next subsections for more details).
Good
- No extra kernel iterations.
- Users can interact with this msg_sender like any other address.
- Unlocks interesting flows (see the partial notes discussion section below).
Bad
- Enshrinement of another thing.
- The act of spending from a stealth address imposes extra steps (~(75 + 44) = ~120 gates).
- Fiddly to keep track of every “masking secret”
S
. - Possibly confusing to devs, if msg_sender is being mutated.
See the table at the top for a more exhaustive summary of good and bad.
Are stealth addresses useful?
A user or an app makes a priv->pub call. Their (masked) address is now associated with some state in public-land.
Q: Why is it useful for the address to be a valid, stealth address?
A: Well, events and notes can then be sent to that address, and the original user can decrypt them.
Q: But encryption in public-land doesn’t make sense; encryption needs to happen in a private function.
A: Sure, but whoever wants to send state (or an event) to this (masked) address can do so via a private function.
Crucially, stealth-address-style masking broadcasts a proper address out into the world, for people to interact with in any number of ways, via private functions. Those interactions don’t even need to be part of the same app as the original priv->pub call.
A noddy example is airdrops (thanks Nico): Some team wants to do an airdrop to all users of an app. Ok, they can do that now, because they have valid, (masked) addresses for all users, so they can send those users notes in a different app.
See also #Stealth-Addresses-vs-Partial-Notes below.
Steath Address Derivation
Nitty gritty section.
Before starting: there are probably 100s of different possible address and stealth address derivation schemes. We’ll need to stop exploring eventually, or we won’t ship. Below are some of the most “obvious” schemes and their tradeoffs.
Aztec Address - Reminder
Let’s remind ourselves how an address is derived. Here’s a screenshot from an old presentation:
So:
// Several ways of describing AddressPoint derivation:
AddressPoint = h(partial_address, h(Ivpk, Npk))*G + ivsk*G
= pre_address*G + ivsk*G
= (pre_address + ivsk)*G
= address_sk*G
= pre_address*G + Ivpk
address = AddressPoint.x; // x-coord
where pre_address
(for want of a better name) (h
in the pic above) is a hash of all the contract bytecode stuff and public keys stuff, and ivsk
is the incoming viewing secret key for the public key Ivpk
.
Of particular importance: pre_address = hash(partial_address, hash(ivsk * G, Npk))
.
Deriving pre_address
from ivsk
in this way cements this ivsk
as the only possible ivsk of this address, to prevent malleability attacks on pre_address + ivsk
, where an attacker could choose any two addends they like.
You can think of (pre_address + ivsk)
as the address_secret_key
.
If we define a stealth address in a similar way to the Ethereum stealth addresses EIP:
Let S
be some ECDH shared secret. (Depending on the use case, it doesn’t necessarily need to be a shared secret if the use case is “singleplayer”).
Then define
// Several ways of describing AddressPoint derivation:
StealthAddressPoint = AddressPoint + h(S)*G
= pre_address*G + Ivpk + h(S)*G
= pre_address*G + ivsk*G + h(S)*G
= (pre_address + (ivsk + h(S))) * G
= stealth_address_sk*G
stealth_address = StealthAddressPoint.x // x-coord
This stealth address has a secret key:
stealth_address_sk = pre_address + ivsk + h(S)
= pre_address + (ivsk + h(S))
.
This is basically the same as the original address_sk
, but with that h(S)
term added.
Since the owner of AddressPoint
is the only entity who can derive stealth_address_sk
(since only they know ivsk
), if someone encrypts to this StealthAddressPoint
, the owner of the original address
can decrypt the ciphertexts.
Notice that the pre_address
of AddressPoint
and StealthAddressPoint
is the same, so if you squint, they both represent the same contract bytecode, and they almost represent the same hard-coded public keys.
That is, the StealthAddressPoint
does map to a valid contract, and it’s the same contract as the original AddressPoint
.
Problems with Stealth Addresses
We try to solve these further below.
- Slightly different keys baked into the stealth address’ pre_address. The
stealth_ivsk := ivsk + h(S)
is not baked into thepre_address
;ivsk
is. That’s a potential problem if the owner ofStealthAddressPoint
ever wanted to spend their notes! - Spending my notes from multiple stealth addresses at once: Notes which are sent to Alice’s various stealth addresses do not end up in the same spendable “pot” of notes:
- The notes belong to different state variables.
- Authwit calls (for permission to edit such states) would currently need to be made to each stealth address. That’d be too much recursion.
- See below for a solution.
- Creating stealth addresses from stealth addresses requires a homomorphic hash. See section below. The Pedersen Hash is a homomorphic hash, but using it requires an extra field to be broadcast. What if someone creates a stealth address from your stealth address? After all, they have no idea which addresses onchain are stealth, and which are “original” addresses. Now you’ll have a doubly-stealth address of the form
A + (h(S') + h(S''))*G
. That’s annoying, because the hash I had in mind (poseidon2) is not homomorphic, meaningh(S' + S'')
is not equal toh(S') + h(S'')
. This means spending from that doubly-stealth address requires different constraints from spending from a singly-stealth address. And what about a triply-stealth address or quadrupley-stealth address? It’s not feasible for a circuit to cope with all spending possibilities, ifh()
is not homomorphic.
Spending Notes from multiple stealth addresses at once
Ideally, we want for notes which are sent to a user’s “original” Address, and notes which are sent to that same user’s many Stealth Addresses, to all end up in the same “bucket” of spendable notes.
The main problems we have to achieving this, are:
The notes belong to different state variables
Storage Slots:
Suppose Alice receives notes to 3 different (but linked) addresses:
A
- original addressA' = A + h(S')*G
A'' = A + h(S'')*G
Then unfortunately, those 3 notes will be stored in 3 different state variables: balances[A]
, balances[A']
, balances[A'']
.
Those state variables have different storage_slot
s: call them: slotA = h(balances.slot, A)
, slotA' = h(balances.slot, A')
, slotA'' = h(balances.slot, A'')
.
This means each of the 3 notes contains a different storage_slot
in its preimage: slotA
, slotA'
, slotA''
.
So given how Aztec_nr is currently designed, if Alice wanted to “Get all notes for balances[A]
”, she’d only receive 1 of her 3 notes, and she’d also only be able to demonstrate (to aztec_nr) that 1 of her 3 notes belongs to address A
.
But what we actually want to happen is for Alice to say “Get all notes for balances[A]
”, and for Alice to receive all 3 of her notes, and also to be able to demonstrate (to aztec_nr) that all 3 of her notes belong to address A
.
Let’s look at some low-level pseudocode for how this might look:
// Get me all notes belonging to address A:
let notes = balances[address].get_notes();
let A = address.to_address_point();
for note in notes {
/********
* This is the only part that's different versus current aztec_nr
* when we consider extra constraints to cope with stealth addresses!!!
********/
// Hand-wavy: we need a way to hint the S values.
//
// Note also: these might need to be siloed per contract address,
// to avoid malicious contracts from linking your
// stealth addresses with your main address (by making
// calls to this oracle)!
// That might diminish the usefulness of stealth addresses,
// if you can't use the same one across apps. We'll see.
let is_stealth_address: bool = oracle.is_stealth_address(note.owner);
let A' = if is_stealth_address {
let S' = oracle.get_stealth_address_shared_secret(note.owner);
// An extra ~600 constraints for the ECADD & ECMUL
A + h(S') * G // elliptic curve add & mul.
else {
A
}
/********
* End of different part.
* Everything below is effectively the same as what aztec_nr currently does.
********/
let slotA' = h(balances.slot, A');
assert(note.owner == A'.x);
assert(note.storage_slot == slotA');
// The above assertions demonstrate that the owner of
// original address A is also the owner of A',
// and that notes which belong to slotA' also belong
// to A.
// These aren't what the aztec_nr functions are called.
// It's pseudocode to show you what's happening.
// Reconstruct the address from the Npk, to prove the Npk is correct.
let { Ivpk, Npk } = oracle.get_master_public_keys(A);
let partial_address = oracle.get_partial_address(A);
let pre_address = hash(partial_address, hash(Ivpk, Npk));
assert(A == pre_address * G + Ivpk);
// Now demonstrate knowledge of nsk_app, relative to Npk.
let nsk_app = oracle.get_nsk_app(Npk);
// This call safely proves the link between nsk_app and Npk,
// in the Kernel circuit (since only the kernel may know nsk):
// assert(Npk == nsk * G);
// assert(nsk_app == h(nsk, app_contract_address));
// And hence they're linked.
context.push_key_validation_request(Npk, nsk_app);
// And hence I know the nsk_app for address A, so I can go ahead and nullify the note.
let note_hash = h(...note);
let nullifier = h(note_hash, nsk_app);
context.push_new_nullifier(nullifier);
}
Authwit calls would currently need to be made to each stealth address
The above code snippet also solves this problem. Above, we demonstrated the link between A
and any stealth address A'
. So now the contract can happily make an authwit call solely to the “original” account contract A
.
With the code above, an app contract would never be “tricked” into calling a stealth address, because A
is derived differently from all A'
.
Unfortunately, if some other user needs to make an authwit call to your stealth address, you would need to show them the derivation from your original address, so that they may demonstrate that the correct bytecode is being used. That kind of defeats the purpose of stealth addresses. Even if you could mask the bytecode with a salt (see “salty stealth addresses” below), there’s still that + Ivpk
term in the stealth address derivation that leaks the original owner.
Hmmm… that makes me wonder if we can re-randomise Ivpk
when stealthifying an address…
Re-randomising Ivpk
Maybe skip this subsection.
Summary
Reminding ourselves of what we have:
A = h(partial_address, h(Ivpk, Npk))*G + Ivpk
= pre_address*G + Ivpk
Let’s use the random shared secret S'
to randomise the Ivpk
.
How about:
A' = h(S')*h(partial_address, h(Ivpk, Npk))*G + h(S')*Ivpk + h(S')*G
Whilst this enables someone to compute a stealth address for you, from A
, another person who wishes to call this stealth address would still be able to see the keys_hash = h(Ivpk, Npk)
when computing the pre_address
, so it doesn’t solve our problem.
The caller would also need to know h(S')
and using that they could reverse-derive the Ivpk
from h(S')*Ivpk
, so this is terrible.
This also feels like it might have malleability problems, enabling an attacker with two such stealth addresses to learn the underlying A
. And possibly if you tried to sign with this address, an attacker might be able to forge signatures. TBD.
How about:
A' = h(partial_address, h(h(S')*Ivpk, h(S')*Npk))*G + h(S')*Ivpk + h(h(h(S')))*G
Someone else would not be able to compute this from A
alone, which is a shame; they’d need the underlying address preimage data.
The triple hash is to prevent others from learning h(S')
(only the owner and creator of the stealth address would know it).
How about re-defining A
, for fun (you’re having fun, right?):
A = contract_class_id*G1 + initialisation_hash*G2 + secret_salt*G3 + h(Npk)*G4 + Ivpk
= contract_class_id*G1 + Stuff
A stealth address is generated by swapping the secret_salt
to secret_salt'
:
A' = contract_class_id*G1 + initialisation_hash*G2 + secret_salt'*G3 + h(Npk)*G4 + Ivpk
= contract_class_id*G1 + Stuff'
This completely changes the tradeoffs, and I’m probably not being serious here. It does tick some cool boxes, though:
- A caller of this
A'
only learns thecontract_class_id
, and not other revealing information about the original address (sinceStuff'
is a hiding Pedersen commitment). - Someone can create a stealth address
A'
fromA
. - Someone can create a stealth address from a stealth address (more on this in the next subsection).
The worst thing about this is: an ephemeral public key to encrypt to such an address A
would be 5 compressed elliptic curve points, which is unacceptably big.
That subsection was pretty disgusting and hastily explained. I rushed it, so the above might not work. I’ll need to think about it for longer. Let’s forget the suggestions of that section and move on.
Creating stealth addresses from stealth addresses requires a homomorphic hash
No one will have any idea which addresses onchain are stealth, and which are “original” addresses. That’s the whole point.
But what if someone creates a stealth address from your stealth address?
A'' = A' + h(S'')*G
Now you’ll have a “doubly-stealth” address. If you were to try to derive that address from the “original” address A
, you’d do:
A'' = A' + h(S'')*G
= (A + h(S')*G) + h(S'')*G
= A + (h(S') + h(S''))*G
That’s annoying, because the hash I had in mind (poseidon2) is not homomorphic, meaning h(S' + S'')
is not equal to h(S') + h(S'')
. This means spending from that doubly-stealth address requires different constraints from spending from a singly-stealth address. (It requires the spender to compute (h(S') + h(S''))
instead of h(something)
):
A' = A + h(S')*G // one hash
A'' = A + (h(S') + h(S''))*G // two hashes. sad face.
And what about a triply-stealth address or quadrupley-stealth address? It’s not feasible for a smart contract to compile various “sizes” of the same function to cope with all spending possibilities, if h()
is not homomorphic.
Pedersen is homomorphic
Instead of using h(S')
, we can change the derivation of a stealth address to be:
Let S'
be the ecdh shared secret, as normal. Let s' = S'.x
; the x-coordinate of S'
.
A' = A + s' * H
for a new elliptic curve generator point H
.
This works (it gives us a homomorphic stealth address), because the stealth address becomes Pedersen-commitment-like. But it’s really annoying and results in an extra field being broadcast if you want to encrypt to this stealth address definition.
Why does it work?
A'' = A' + s''*H
= (A + s'*H) + s''*H
= A + (s' + s'')*H
Hooray! A person can create a stealth address A''
from a stealth address A'
, and the owner of the original address can prove the derivation of A''
from A
with the same constraints as proving the derivation of A'
from A
:
A' = A + s'*H
A'' = A + t *H // where t = s' + s''. Same constraints to derive both A' and A'' from A!
So the circuit which spends notes owned by any of A, A', A''
can do so with the same stealth address derivation constraints.
Malleability
Why did we need to introduce H
? Why can’t we just use G
(without the hash h(S')
and have a stealth address be defined as:
// Bad definition
A' = A + s'*G
It’s because of malleability.
Let’s write out the full derivation of A
, under this “bad definition”:
// Bad definition
A' = h(partial_address, h(Ivpk, Npk))*G + ivsk*G + s'*G
Having s'
“floating freely” like that (instead of h(S')*G
or s'*H
) means the owner of A'
can lie about their partial_address, ivsk, Ivpk, Npk
. They can choose rival values ~partial_address, ~ivsk, ~Ivpk, ~Npk
, and derive a malicious shared secret:
~s' = h(partial_address, h(Ivpk, Npk)) + ivsk + s' - h(~partial_address, h(~Ivpk, ~Npk)) - ~ivsk'
such that:
// Malicious derivation of bad defn of A':
A' = h(~partial_address, h(~Ivpk, ~Npk))*G + ~ivsk*G + ~s'*G
This malleability attack enables the owner of A'
to:
- Nullify a note (owned by
A'
) ~infinite times under different nullifier keys~Npk
. - Demonstrate that the underlying account contract of
A'
is any contract they like, by providing any~partial_address
. - Make authwit calls to any contract they like, since they can demonstreate any account contract is theirs.
Why does s'*H
result in an extra field being broadcast, when encrypting?
Ignoring stealth addresses momentarily, the reason our address scheme looks a bit covoluted is because we sought to keep an ephemeral public key (used for ecdh secret sharing) as a single compressed point (1 Field & 1 bit):
Originally:
// Old address derivation:
address = h(partial_address, h(Ivpk, Npk))
This wasn’t an elliptic curve point, so we couldn’t “encrypt to it”. If someone asked “Hey, what’s your address”, you’d need to given them address
and Ivpk
(otherwise they wouldn’t be able to encrypt to you), and partial_address
& Npk
(otherwise they wouldn’t be able to prove the correctness of Ivpk
relative to address
).
Then:
We considered making an address a Pedersen commitment:
// Candidate address derivation - never fleshed out - never adopted.
AddressPoint = partial_address*H + ivsk*G
But the ephemeral public key – when encrypting – would have become two compressed points:
// A sender would compute:
Eph_pk = [eph_sk * H, eph_sk * G] // two compressed points to be broadcast
Shared_secret = eph_sk * Address
// The recipient would compute:
Shared_secret = partial_address*Eph_pk[0] + ivsk*Eph_pk[1]
// ... which is equiv to (but the recipient doesn't know eph_sk):
= partial_address*(eph_sk*H) + ivsk*(eph_sk*G)
= eph_sk * Address
We didn’t like there being two compressed points being broadcast, so we didn’t finish designing that address scheme in such a way that Ivpk & Npk were constrained to be a part of the address.
Now:
We sought to use G
for everything, and ended up with:
// Current address:
AddressPoint = h(partial_address, h(Ivpk, Npk))*G + ivsk*G
But:
With this stealth address proposal – of introducing s'*H
– we’re in danger of broadcasting that extra pesky compressed point:
StealthAddressPoint = h(partial_address, h(Ivpk, Npk))*G + ivsk*G + s'*H
// A sender would compute:
Eph_pk = [eph_sk * G, eph_sk * H] // two compressed points to be broadcast
Shared_secret = eph_sk * StealthAddressPoint
Shared_secret = (h(partial_address, h(Ivpk, Npk)) + ivsk)*Eph_pk[0] + s'*Eph_pk[1]
// ... which is equiv to (but the recipient doesn't know eph_sk):
= (h(partial_address, h(Ivpk, Npk)) + ivsk)*(esph_sk*G) + s'*(eph_sk*H)
= eph_sk * (h(partial_address, h(Ivpk, Npk))*G + ivsk*G + s'*H)
= eph_sk * StealthAddressPoint
It works, we get lots of lovely properties, but for each encrypted log, the Eph_pk is 2 Fields & 2 bits.
Should all encrypted logs even use an ephemeral public key?
In cases where the sender and recipient can handshake in advance, a sequence of tags can be derived from a handshaking shared secret. I reckon the symmetric key of any ciphertexts can therefore be derived from the handshake shared secret, instead of from an ephemeral shared secret:
tag_i = h(hs_shared_secret, i); // for an incrementing index i
symmetric_key_i_j = h(hs_shared_secret, i, j); // for example - I haven't thought about this much
I guess a problem is in our security model for keys. If the handshake shared secret is leaked currently, it doesn’t enable the attacker to decrypt; it only enables the attacker to link ciphertexts to this user. The handshaking shared secret is arguably in a “less secure” environment than the ivsk
(to be debated, depending on the wallet’s setup), and so is more likely to leak. Therefore, if we made this change, an attacker who learns the handshake shared secret would be able to decrypt all of the user’s notes and messages. Bad.
What happens if a stealth app or account contract is called?
There are some problems:
- A stealth address will contain different state from the “original” address:
- Different state updates over time.
- Different initialisation (or possibly no initialisation at all!), but the constructor args baked into the
pre_address
are those inherited from the original address!!!
- To call a stealth address requies its bytecode, which then leaks the preimage of the
pre_address
, which then leaks the true owner of the contract, which then invalidates the “stealthiness” of the stealth address. - Whiltelists/Blacklists could be circumvented.
A simple solution is to never allow calls to a stealth address.
A more complex solution (which I haven’t explored, because it’s complicated) is to somehow enable calls to stealth addresses.
Preventing calls to stealth addresses
The above big code snippet shows how one can prove that a stealth address A'
relates to an original address A
. The caller of a stealth address could demonstrate to the kernel (through a hint) that the target A'
is derived from A
, and hence A
should be called.
Enabling calls to stealth addresses
? Not sure how, or of the benefit.
You might need to solve:
- Providing the class_id and initialisation nullifier of a stealth address without leaking the keys (which are leaky) and the initialisation hash (which is also leaky unless it’s salted).
- Enabling someone to create a stealth address for you, in such a way that the initialisation hash and keys are re-salted.
Whitelists/Blacklists could be circumvented
The big code snippet above shows logic that an app could implement if it wants to enforce a whitelist / blacklist: it can constrain that the original address is used, by checking the address’s derivation.