Skip to content

Commit

Permalink
feat!: alternative key registry contract (#7523)
Browse files Browse the repository at this point in the history
This is a tentative redesign of the key registry contract, which lets us
read all 4 keys in a single merkle inclusion proof, while also
performing fewer public storage writes. It relies on `PublicMutable`
with custom deadlines instead of `SharedMutable` as the key registry
must be lax during reads to prevent abuse from accounts performing
frequent rotation.

This is meant to be a proof of concept and reference for discussion, so
there are no tests yet. If we were to adopt this contract it should be
relatively straightforward to replace the current getters with
`get_current_public_keys`.

---

Update: we've now decided to move forward with this design, even if
whether we actually do have full key rotation is up for debate. This is
a good middle ground in terms of the code being performant and easy to
either add or remove rotation in the future.

What this PR does is introduce the new registry and switch all old
contracts to use it. They'll be using historical mode, i.e. reading keys
at a specific block in the past instead of reading the current keys,
resulting in no max block number constraints. This is a departure from
the current behavior, but is only temporary until we switch over from
the old API to the new one
(#7953) so that we
can then benefit from reduce gate counts
(#7954). I've also
left the original contract, libraries, etc., unmodified so as to later
delete them cleanly instead of making this PR too messy
(#7955).

---------

Co-authored-by: Jan Beneš <[email protected]>
  • Loading branch information
nventuro and benesjan authored Aug 14, 2024
1 parent cea8295 commit 3e6a20f
Show file tree
Hide file tree
Showing 23 changed files with 387 additions and 224 deletions.
13 changes: 10 additions & 3 deletions docs/docs/aztec/concepts/accounts/keys.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
---
title: Keys
tags: [accounts, keys]
---

The goal of this section is to give app developer a good idea what keys there are used in the system.
For a detailed description head over to the [protocol specification](../../../protocol-specs/addresses-and-keys/index.md).
Expand All @@ -24,6 +22,7 @@ Instead it's up to the account contract developer to implement it.
:::

## Public keys retrieval

The keys can either be retrieved from a key registry contract or from the [Private eXecution Environment (PXE)](../pxe/index.md).

:::note
Expand All @@ -34,7 +33,7 @@ There is 1 key registry and its address is hardcoded in the protocol code.

To retrieve them a developer can use one of the getters in Aztec.nr:

#include_code key-getters /noir-projects/aztec-nr/aztec/src/keys/getters.nr rust
#include_code key-getters /noir-projects/aztec-nr/aztec/src/keys/getters/mod.nr rust

If the keys are registered in the key registry these methods can be called without any setup.
If they are not there, it is necessary to first register the user as a recipient in our PXE.
Expand All @@ -51,6 +50,7 @@ Then to register the recipient's [complete address](#complete-address) in PXE we
During private function execution these keys are obtained via an oracle call from PXE.

## Key rotation

To prevent users from needing to migrate all their positions if some of their keys are leaked we allow for key rotation.
Key rotation can be performed by calling the corresponding function on key registry.
E.g. for nullifier key:
Expand All @@ -62,6 +62,7 @@ This means that it will be possible to nullify the notes with the same old key a
These guardrails are typically in place so a user should not lose her notes even if this unfortunate accident happens.

## Scoped keys

To minimize damage of potential key leaks the keys are scoped (also called app-siloed) to the contract that requests them.
This means that the keys used for the same user in two different application contracts will be different and potential leak of the scoped keys would only affect 1 application.

Expand All @@ -78,9 +79,11 @@ This is intentional and instead of directly trying to derive `Npk_m` from `nsk_a
If you are curious how the derivation scheme works head over to [protocol specification](../../../protocol-specs/addresses-and-keys/example-usage/nullifier#diagram).

## Protocol key types

All the keys below are Grumpkin keys (public keys derived on the Grumpkin curve).

## Nullifier keys

Whenever a note is consumed, a nullifier deterministically derived from it is emitted.
This mechanisms prevents double-spends, since nullifiers are checked by the protocol to be unique.
Now, in order to preserve privacy, a third party should not be able to link a note hash to its nullifier - this link is enforced by the note implementation.
Expand All @@ -95,15 +98,18 @@ Typically, `Npk_m` is stored in a note and later on, the note is nullified using
Validity of `nsk_app` is verified by our [protocol kernel circuits](../../../protocol-specs/circuits/private-kernel-tail#verifying-and-splitting-ordered-data).

## Incoming viewing keys

The public key (denoted `Ivpk`) is used to encrypt a note for a recipient and the corresponding secret key (`ivsk`) is used by the recipient during decryption.

## Outgoing viewing keys

App-siloed versions of outgoing viewing keys are denoted `ovsk_app` and `Ovpk_app`.
These keys are used to encrypt a note for a note sender which is necessary for reconstructing transaction history from on-chain data.
For example, during a token transfer, the token contract may dictate that the sender encrypts the note with value with the recipient's `Ivpk`, but also records the transfer with its own `Ovpk_app` for bookkeeping purposes.
If these keys were not used and a new device would be synched there would be no "direct" information available about notes that a user created for other people.

## Tagging keys

Used to compute tags in a [tagging note discovery scheme](../../../protocol-specs/private-message-delivery/private-msg-delivery#note-tagging).

:::note
Expand Down Expand Up @@ -152,6 +158,7 @@ Since there are no restrictions on the actions that an account contract may exec
### Complete address

When deploying a contract, the contract address is deterministically derived using the following scheme:

<!-- TODO: link contract deployment here once the updated section exists -->

```
Expand Down
2 changes: 1 addition & 1 deletion l1-contracts/src/core/libraries/ConstantsGen.sol
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ library Constants {
uint256 internal constant L2_GAS_PER_NOTE_HASH = 32;
uint256 internal constant L2_GAS_PER_NULLIFIER = 64;
uint256 internal constant CANONICAL_KEY_REGISTRY_ADDRESS =
13457222047904330765774796260088567201269649167356521005501223652902339211182;
21209182303070804160941065409360795406831433542792830301721453026531461944353;
uint256 internal constant CANONICAL_AUTH_REGISTRY_ADDRESS =
16522644890256297179255458951626875692461008240031142745359776058397274208468;
uint256 internal constant DEPLOYER_CONTRACT_ADDRESS =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ impl UnconstrainedContext {
Self { block_number, contract_address, version, chain_id }
}

unconstrained fn at_historical(contract_address: AztecAddress, block_number: u32) -> Self {
let chain_id = get_chain_id();
let version = get_version();
Self { block_number, contract_address, version, chain_id }
}

fn block_number(self) -> u32 {
self.block_number
}
Expand Down
2 changes: 1 addition & 1 deletion noir-projects/aztec-nr/aztec/src/history/public_storage.nr
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ trait PublicStorageHistoricalRead {
fn public_storage_historical_read(header: Header, storage_slot: Field, contract_address: AztecAddress) -> Field;
}

impl PublicStorageHistoricalRead for Header {
impl PublicStorageHistoricalRead for Header {
fn public_storage_historical_read(self, storage_slot: Field, contract_address: AztecAddress) -> Field {
// 1) Compute the leaf slot by siloing the storage slot with the contract address
let public_data_tree_index = poseidon2_hash_with_separator(
Expand Down
107 changes: 0 additions & 107 deletions noir-projects/aztec-nr/aztec/src/keys/getters.nr

This file was deleted.

154 changes: 154 additions & 0 deletions noir-projects/aztec-nr/aztec/src/keys/getters/mod.nr
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
use dep::protocol_types::{
header::Header, abis::validation_requests::KeyValidationRequest, address::AztecAddress,
constants::CANONICAL_KEY_REGISTRY_ADDRESS, point::Point, storage::map::derive_storage_slot_in_map,
traits::is_empty
};
use crate::{
context::{PrivateContext, UnconstrainedContext},
oracle::{keys::get_public_keys_and_partial_address, key_validation_request::get_key_validation_request},
keys::{
public_keys::{PublicKeys, PUBLIC_KEYS_LENGTH}, stored_keys::StoredKeys,
constants::{NULLIFIER_INDEX, INCOMING_INDEX, OUTGOING_INDEX, TAGGING_INDEX}
},
state_vars::{
shared_mutable::shared_mutable_private_getter::SharedMutablePrivateGetter,
public_mutable::PublicMutable, map::Map
}
};

global DELAY = 5;

mod test;

// docs:start:key-getters
trait KeyGetters {
fn get_npk_m(header: Header, context: &mut PrivateContext, address: AztecAddress) -> Point;
fn get_ivpk_m(header: Header, context: &mut PrivateContext, address: AztecAddress) -> Point;
fn get_ovpk_m(header: Header, context: &mut PrivateContext, address: AztecAddress) -> Point;
fn get_tpk_m(header: Header, context: &mut PrivateContext, address: AztecAddress) -> Point;
fn get_npk_m_hash(header: Header, context: &mut PrivateContext, address: AztecAddress) -> Field;
}

impl KeyGetters for Header {
fn get_npk_m(self, _context: &mut PrivateContext, address: AztecAddress) -> Point {
get_historical_public_keys(self, address).npk_m
}

fn get_ivpk_m(self, _context: &mut PrivateContext, address: AztecAddress) -> Point {
get_historical_public_keys(self, address).ivpk_m
}

fn get_ovpk_m(self, _context: &mut PrivateContext, address: AztecAddress) -> Point {
get_historical_public_keys(self, address).ovpk_m
}

fn get_tpk_m(self, _context: &mut PrivateContext, address: AztecAddress) -> Point {
get_historical_public_keys(self, address).tpk_m
}

fn get_npk_m_hash(self, context: &mut PrivateContext, address: AztecAddress) -> Field {
self.get_npk_m(context, address).hash()
}
}
// docs:end:key-getters

// A helper function since requesting nsk_app is very common
// TODO(#6543)
pub fn get_nsk_app(npk_m_hash: Field) -> Field {
get_key_validation_request(npk_m_hash, NULLIFIER_INDEX).sk_app
}

// This is the number of blocks that must pass after a key rotation event until the old keys are fully phased out and
// become invalid.
global KEY_REGISTRY_UPDATE_BLOCKS = 5;

global KEY_REGISTRY_STORAGE_SLOT = 1;

// Returns all current public keys for a given account, applying proper constraints to the context. We read all
// keys at once since the constraints for reading them all are actually fewer than if we read them one at a time - any
// read keys that are not required by the caller can simply be discarded.
pub fn get_current_public_keys(context: &mut PrivateContext, account: AztecAddress) -> PublicKeys {
// We're going to perform historical reads from public storage, and so need to constrain the caller so that they
// cannot use very old blocks when constructing proofs, and hence e.g. read very old keys. We are lax and allow
// _any_ recent block number to be used, regardless of whether there may have been a recent key rotation. This means
// that multiple sets of keys are valid for a while immediately after rotation, until the old keys become phased
// out. We *must* be lax to prevent denial of service and transaction fingerprinting attacks by accounts that rotate
// their keys frequently.
// Note that we constrain the max block number even if the registry ends up being empty: this ensures that proof of
// an empty registry is also fresh.
let current_header = context.get_header();
context.set_tx_max_block_number(current_header.global_variables.block_number as u32 + KEY_REGISTRY_UPDATE_BLOCKS);

get_historical_public_keys(current_header, account)
}

// Returns historical public keys for a given account at some block determined by a block header. We read all keys at
// once since the constraints for reading them all are actually fewer than if we read them one at a time - any read keys
// that are not required by the caller can simply be discarded.
// WARNING: if called with a historical header created from a fixed block this function will explicitly ignore key
// rotation! This means that callers of this may force a user to use old keys, potentially leaking privacy (e.g. if the
// old keys were leaked). Only call this function with a header from a fixed block if you understand the implications of
// breaking key rotation very well.
pub fn get_historical_public_keys(historical_header: Header, account: AztecAddress) -> PublicKeys {
// TODO: improve this so that we always hint the correct set of keys (either registry or canonical) and hash them
// once instead of having two different hints and twice as many constraints due to the double hashing.

// The key registry is the primary source of information for keys, as that's where accounts store their new keys
// when they perform rotation. The key registry conveniently stores a hash of each user's keys, so we can read that
// single field and then prove that we know its preimage (i.e. the current set of keys).
let key_registry_hash = key_registry_hash_public_historical_read(historical_header, account);
if key_registry_hash != 0 {
let hinted_registry_public_keys = key_registry_get_stored_keys_hint(
account,
historical_header.global_variables.block_number as u32
);
assert_eq(hinted_registry_public_keys.hash().to_field(), key_registry_hash);

hinted_registry_public_keys
} else {
// If nothing was written to the registry, we may still be able to produce the correct keys if we happen to know
// the canonical set (i.e. the ones that are part of the account's preimage).
let (hinted_canonical_public_keys, partial_address) = get_public_keys_and_partial_address(account);
assert_eq(
account, AztecAddress::compute(hinted_canonical_public_keys.hash(), partial_address), "Invalid public keys hint for address"
);

hinted_canonical_public_keys
}
}

fn key_registry_hash_public_historical_read(historical_header: Header, account: AztecAddress) -> Field {
// The keys are stored in a Map that is keyed with the address of each account, so we first derive the corresponding
// slot for this account.
let keys_storage_slot = derive_storage_slot_in_map(KEY_REGISTRY_STORAGE_SLOT, account);

// The keys are stored as [ ...serialized_keys, hash ], and since arrays get allocated sequential storage slots
// (prior to siloing!), we simply add the length to the base slot to get the last element.
let hash_storage_slot = keys_storage_slot + PUBLIC_KEYS_LENGTH as Field;

historical_header.public_storage_historical_read(
hash_storage_slot,
AztecAddress::from_field(CANONICAL_KEY_REGISTRY_ADDRESS)
)
}

unconstrained fn key_registry_get_stored_keys_hint(account: AztecAddress, block_number: u32) -> PublicKeys {
// This is equivalent to the key registry contract having an unconstrained getter that we call from an oracle, but
// PXE does not yet support that functionality so we do this manually instad. Note that this would be a *historical*
// call!

// TODO (#7524): call the unconstrained KeyRegistry.get_current_keys() function instead

let context = UnconstrainedContext::at_historical(
AztecAddress::from_field(CANONICAL_KEY_REGISTRY_ADDRESS),
block_number
);
let keys_storage = Map::new(
context,
KEY_REGISTRY_STORAGE_SLOT,
|context, slot| { PublicMutable::new(context, slot) }
);

let stored_keys: StoredKeys = keys_storage.at(account).read();
stored_keys.public_keys
}
Loading

0 comments on commit 3e6a20f

Please sign in to comment.