-
Notifications
You must be signed in to change notification settings - Fork 562
Description
Currently
We currently have this big warning:
impl<Note, Context> PrivateMutable<Note, Context> {
// docs:start:new
pub fn new(context: Context, storage_slot: Field) -> Self {
assert(storage_slot != 0, "Storage slot 0 not allowed. Storage slots must start from 1.");
Self { context, storage_slot }
}
// docs:end:new
// The following computation is leaky, in that it doesn't hide the storage slot that has been initialized, nor does it hide the contract address of this contract.
// When this initialization nullifier is emitted, an observer could do a dictionary or rainbow attack to learn the preimage of this nullifier to deduce the storage slot and contract address.
// For some applications, leaking the details that a particular state variable of a particular contract has been initialized will be unacceptable.
// Under such circumstances, such application developers might wish to _not_ use this state variable type.
// This is especially dangerous for initial assignment to elements of a `Map<AztecAddress, PrivateMutable>` type (for example), because the storage slot often also identifies an actor. e.g.
// the initial assignment to `my_map.at(msg.sender)` will leak: `msg.sender`, the fact that an element of `my_map` was assigned-to for the first time, and the contract_address.
// Note: subsequent nullification of this state variable, via the `replace` method will not be leaky, if the `compute_nullifier()` method of the underlying note is designed to ensure privacy.
// For example, if the `compute_nullifier()` method injects the secret key of a note owner into the computed nullifier's preimage.
pub fn compute_initialization_nullifier(self) -> Field {
poseidon2_hash_with_separator(
[self.storage_slot],
GENERATOR_INDEX__INITIALIZATION_NULLIFIER,
)
}
}I think it's just a fiddly abstraction that we've ended up with.
Inferring an "owner" (nelly)
If we didn't have these rigid interfaces and abstractions, there are times where a non-leaking initialization_nullifier can be computed.
Note: "owner" is an ambiguous term, so I use the term "nelly" to mean "the person whose nsk is used to nullify the note".
E.g. if we could infer a nelly for a state variable, then we could initialise it with:
initialization_nullifier = hash(nelly_nsk, storage_slot).
There are various ways we can infer such an owner (you know all of this):
nellys_state = Note {} // nelly is constant for this state
foo = Note { nelly, ... } // nelly is known to the note
foo[key] = Note { nelly, ... } // nelly is known to the note
foo[nelly] = Note {} // nelly is the mapping key
bar[key][nelly] = Note {} // nelly is the 2nd mapping key
bar[nelly][key] = Note {} // nelly is the 1st mapping key
bar[key1][key2] = Note { nelly } // nelly is known to the noteSo we have either:
- nelly is constant
- nelly is known to the note (i.e. it is a field of the note)
- nelly is one of the (possibly nested) mapping keys
The problem becomes:
- How does the dev convey how nelly is conveyed?
- Where to link
nellywithnelly_nsk? - How to pass the correct
storage_slotinto the initialization nullifier computation?
I'm not sure, but it illustrates that the current design of compute_initialization_nullifier is an avoidable privacy footgun, because the information is in the circuit, somewhere.
Insanity
Bear with me, we go back on ourselves in the next section, to have a simple stop-gap
We don't currently have a way to access the key of the parent container in which a state variable "lives".
E.g. my_mapping: Map<Field, PrivateMutable<MyNote, Context>, Context>
The PrivateMutable cannot access its parent Map container or its mapping key.
We could maybe fix this, by giving each state variable a reference to its parent container (if such a container exists), and/or give each state variable a reference the they key which led to it. E.g.:
pub struct PrivateMutable<Note, Context, ParentT, ParentKeyT> {
context: Context,
storage_slot: Field,
parent_container: Option<ParentT>,
parent_key: Option<ParentKeyT>,
nelly_locator: u32,
}That's a bit cumbersome. Imagine how glorious the storage declarations become for users, with those extra generic parameters.
In c++, you could probably use a pointer to convey the parent without any extra template params. Anyway, in Noir we'd get this monstrosity:
my_mapping: Map<Field, PrivateMutable<MyNote, Context, Option<Map<T>>, Option<Field>>, Context>
(It's also a bit cyclic, so I've copped-out and put a T in the middle there).
But it would be powerful. Stay with me, you're having fun...
I've then put a nelly_locator in there, to enable the dev to indicate how the nelly should be located by the state variable.
This nelly_locator could be set in the new function of PrivateMutable as a way of saying "I am declaring this variable, and the "nelly" will always be one of:
- 0: nelly is assumed to be known to the note
- 1: nelly is known to the parent
- 2: nelly is know to the parent of the parent
- 3: ...
pub fn new(context: Context, storage_slot: Field, parent_container: Option<ParentT>, parent_key: Option<ParentKeyT>, nelly_locator: u32) -> Self {
assert(storage_slot != 0, "Storage slot 0 not allowed. Storage slots must start from 1.");
Self {
context,
storage_slot,
parent_container,
parent_key,
nelly_locator,
}
}Of course, we've hidden-away new behind macros, which for the purposes of this long doc is a real shame, because we take away the dev's ability to convey their understanding of who the nelly of the state is.
The state variable can then access this nelly (by taking the approach conveyed by nelly_locator) and forward nelly to the compute_initialization_nullifier function.
Simpler plz
At the moment, with our compute_nullifier function, we assume the Note always knows who nelly is.
// `self` is a Note
fn compute_nullifier(self, context: &mut PrivateContext, note_hash_for_nullify: Field) -> Field;So we could simplify all the above, and assume that nelly is known to the note.
Interestingly, I do wonder if at some point in the future we'll discover use cases where the
nellyis not conveyed by the note, and is instead conveyed through a mapping key... then we can look back at the "Insanity" section and smile.
But actually, maybe we can always convey thenellythrough a note, even if it's already conveyed through a mapping key. Sure, it's duplication, but it's simple:
my_mapping[nelly] = Note { nelly, ... }<-- job's a good'un.
So if nelly is known to the Note, then we can just ask the note to compute the initialisation nullifier for us. Since it has access to a nelly (sometimes, depending on the Note), it can compute a nsk and compute a non-leaky initialisation nullifier!
Consider, for example, a UintNote, which has an owner: AztecAddress field.
Maybe we should rename
ownertonellyand see how the world reacts.
If the UintNote implemented its own compute_initialization_nullifier, instead of the PrivateMutable state variable, then it has enough contextual information to know that it can inject an nsk into this nullifier computation, to remove any leakiness. Notes already implement a compute_nullifier function:
fn compute_nullifier(self, context: &mut PrivateContext, note_hash_for_nullify: Field) -> Field;Proposed interface:
pub trait NoteHash { // this should be renamed to NoteInterface or something... wait... it used to be... why was this changed?
fn compute_initialization_nullifier(self, context: &mut PrivateContext, storage_slot: Field) -> Field {
// WE USE THE DEFAULT IMPL THAT WE HAVE TODAY, to enable Notes _without_ a defined `nelly`
// to use the current, leaky scheme.
poseidon2_hash_with_separator(
[storage_slot],
GENERATOR_INDEX__INITIALIZATION_NULLIFIER,
)
}
}Then, for notes and/or storage slots that have a clear nelly, those notes can overwrite the impl:
Example implementation:
fn compute_initialization_nullifier(self, context: &mut PrivateContext, storage_slot: Field) -> Field {
let owner_nsk = f(self.owner); // pseudocode
let nullifier = hash(storage_slot, owner_nsk);
}Leaky abstraction
This is a leaky abstraction, because we have that ugly storage_slot concept that is being passed as a parameter into a Note method.
If you hate that, we could call it something more abstract, like contextual_storage_data: Field.
It's not deterministic
No, it's not always, so we'd fall back on the deterministic hash(storage_slot) default.
But in cases where the storage_slot is derived from a nelly address, then in the body of the contract, the dev can do:
my_mapping[nelly].initialize(Note { nelly, ... });... and as long as nelly is always the same in both places, it is deterministic: there is only one valid nullifier.
It's if the dev does this that it's dangerous:
my_mapping[nelly].initialize(Note { bad_rapper, ... });Now we could initialize this storage slot with infinite bad_rapper values. The storage_slot ceases to have any real meaning. It becomes like a Set where notes can be owned by anyone. It's chaos.
Can you think of the names of any bad rappers?
It's a big footgun for devs, that we can't protect against unless we have something like what I wrote about in the "Insanity" section.
Tl;dr
Move the impl to the Note, so that we can access a nelly's nsk.
We keep the same default impl for notes which don't have a nelly, but we enable notes to overwrite it with an impl that can use `nsk.
