Skip to main content

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.

Each account in Aztec is backed by 4 key pairs:

  • A nullifier key pair used for note nullifier computation, comprising the master nullifier secret key (nsk_m) and master nullifier public key (Npk_m).
  • A incoming viewing key pair used to encrypt a note for the recipient, consisting of the master incoming viewing secret key (ivsk_m) and master incoming viewing public key (Ivpk_m).
  • A outgoing viewing key pair used to encrypt a note for the sender, includes the master outgoing viewing secret key (ovsk_m) and master outgoing viewing public key (Ovpk_m).
  • A tagging key pair used to compute tags in a tagging note discovery scheme, comprising the master tagging secret key (tsk_m) and master tagging public key (Tpk_m).
info

All key pairs above are derived from a secret using a ZCash inspired scheme defined in protocol specification.

note

Additionally, there is typically a signing key pair which is used for authenticating the owner of the account. However, since Aztec supports native account abstraction this is not defined in protocol. 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).

note

The key registry is a canonical contract used to store user public keys. Canonical in this context means that it is a contract whose functionality is essential for the protocol. 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:

key-getters
trait KeyGetters {
fn get_npk_m(header: Header, context: &mut PrivateContext, address: AztecAddress) -> GrumpkinPoint;
fn get_ivpk_m(header: Header, context: &mut PrivateContext, address: AztecAddress) -> GrumpkinPoint;
fn get_ovpk_m(header: Header, context: &mut PrivateContext, address: AztecAddress) -> GrumpkinPoint;
fn get_tpk_m(header: Header, context: &mut PrivateContext, address: AztecAddress) -> GrumpkinPoint;
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) -> GrumpkinPoint {
get_master_key(context, address, NULLIFIER_INDEX, self)
}

fn get_ivpk_m(self, context: &mut PrivateContext, address: AztecAddress) -> GrumpkinPoint {
get_master_key(context, address, INCOMING_INDEX, self)
}

fn get_ovpk_m(self, context: &mut PrivateContext, address: AztecAddress) -> GrumpkinPoint {
get_master_key(context, address, OUTGOING_INDEX, self)
}

fn get_tpk_m(self, context: &mut PrivateContext, address: AztecAddress) -> GrumpkinPoint {
get_master_key(context, address, TAGGING_INDEX, self)
}

fn get_npk_m_hash(self, context: &mut PrivateContext, address: AztecAddress) -> Field {
get_master_key(context, address, NULLIFIER_INDEX, self).hash()
}
}
Source code: noir-projects/aztec-nr/aztec/src/keys/getters.nr#L15-L45

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.

First we need to get a hold of recipient's complete address. Below are some ways how we could instantiate it after getting the information in a string form from a recipient:

instantiate-complete-address
// Typically a recipient would share their complete address with the sender
const completeAddressFromString = CompleteAddress.fromString(
'0x09bc7031bb21627cce6aac1dc710ecc92acd8475149c530a4bb57df63d9d6fe902a9372135ce5b49b46102732fabd742c31642543396013dde5b460075864607264c605bc115c6cb92a4db0a6b893fd3777341078693d0af22e3ff53f4c2ee2a2fae73914fc50d325e2707a8e996f1ad498429f715f998225dc6bd2ede05aaee055ee137d28b634322e0ea98afc42dfc48833e8d2879c34d23d6d1d337069cca212af0f28b7865b339e202a0077fd3bd8dddc472d055945ad99c02dcccd28bb22bb3585fca3e5751c9913521a390458d63e4d9b292e4872582f3b13da214470c14083a4567cf4f1e92696e6c01923bc6a8b414159446268b12fe8669ce44f1f5196561aca6c654d2405a5653002cba5552b50b6ce1afc9515ed6682507abcb3010040d791aeb30138efc9c7d36b47684af2f26f686672448349f05934ae7bbbf',
);

// Alternatively, a recipient could share the individual components with the sender
const address = Fr.fromString('0x09bc7031bb21627cce6aac1dc710ecc92acd8475149c530a4bb57df63d9d6fe9');
const npkM = Point.fromString(
'0x02a9372135ce5b49b46102732fabd742c31642543396013dde5b460075864607264c605bc115c6cb92a4db0a6b893fd3777341078693d0af22e3ff53f4c2ee2a',
);
const ivpkM = Point.fromString(
'0x2fae73914fc50d325e2707a8e996f1ad498429f715f998225dc6bd2ede05aaee055ee137d28b634322e0ea98afc42dfc48833e8d2879c34d23d6d1d337069cca',
);
const ovpkM = Point.fromString(
'0x212af0f28b7865b339e202a0077fd3bd8dddc472d055945ad99c02dcccd28bb22bb3585fca3e5751c9913521a390458d63e4d9b292e4872582f3b13da214470c',
);
const tpkM = Point.fromString(
'0x14083a4567cf4f1e92696e6c01923bc6a8b414159446268b12fe8669ce44f1f5196561aca6c654d2405a5653002cba5552b50b6ce1afc9515ed6682507abcb30',
);

const partialAddress = Fr.fromString('0x10040d791aeb30138efc9c7d36b47684af2f26f686672448349f05934ae7bbbf');

const completeAddressFromComponents = new CompleteAddress(
address,
new PublicKeys(npkM, ivpkM, ovpkM, tpkM),
partialAddress,
);
Source code: yarn-project/circuits.js/src/structs/complete_address.test.ts#L38-L66

Then to register the recipient's complete address in PXE we would call registerRecipient PXE endpoint using Aztec.js:

register-recipient
await pxe.registerRecipient(completeAddress);
Source code: yarn-project/aztec.js/src/wallet/create_recipient.ts#L11-L13

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:

key-rotation
await keyRegistry
.withWallet(wallets[0])
.methods.rotate_npk_m(wallets[0].getAddress(), firstNewMasterNullifierPublicKey, Fr.ZERO)
.send()
.wait();
Source code: yarn-project/end-to-end/src/e2e_key_registry.test.ts#L153-L159

Note that the notes directly contain Npk_m. This means that it will be possible to nullify the notes with the same old key after the key rotation and attacker could still potentially steal them if there are no other guardrails in place (like for example account contract auth check). 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.

This also allows per-application auditability. A user may choose to disclose their incoming and outgoing viewing keys for a given application to an auditor or regulator (or for 3rd party interfaces, e.g. giving access to a block explorer to display my activity), as a means to reveal all their activity within that context, while retaining privacy across all other applications in the network.

In the case of nullifier keys, there is also a security reason involved. Since the nullifier secret is exposed to the application contract to be used in the nullifier computation, the contract may accidentally or maliciously leak it. If that happens, only the nullifier secret for that application is compromised (nsk_app and not nsk_m).

Above we mentioned that the notes typically contain Npk_m. It might seem like a mistake given that the notes are nullified with nsk_app. This is intentional and instead of directly trying to derive Npk_m from nsk_app we instead verify that both of the keys were derived from the same nsk_m in our protocol circuits. If you are curious how the derivation scheme works head over to protocol specification.

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. Therefore, calculating the nullifier for a note requires a secret from its owner.

An application in Aztec.nr can request a secret from the current user for computing the nullifier of a note via the request_nullifier_secret_key API:

nullifier

fn compute_nullifier(self, context: &mut PrivateContext) -> Field {
let note_hash_for_nullify = compute_note_hash_for_consumption(self);
let secret = context.request_nsk_app(self.npk_m_hash);
poseidon2_hash([
note_hash_for_nullify,
secret,
GENERATOR_INDEX__NOTE_NULLIFIER as Field,
])
}
Source code: noir-projects/aztec-nr/value-note/src/value_note.nr#L25-L37

Typically, Npk_m is stored in a note and later on, the note is nullified using the secret app-siloed version (denoted nsk_app). nsk_app is derived by hashing nsk_m with the app contract address and it is necessary to present it to compute the nullifier. Validity of nsk_app is verified by our protocol kernel circuits.

Incoming viewing keys

The app-siloed version of public key (denoted Ivpk_app) is used to encrypt a note for a recipient and the corresponding secret key (ivsk_app) is used by 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_app, 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.

note

Tagging note discovery scheme won't be present in our testnet so we are intentionally not providing you with much info yet.

Signing keys

As mentioned above signing keys are not defined in protocol because of account abstraction and instead the key scheme is defined by the account contract.

Usually, an account contract will validate a signature of the incoming payload against a known signing public key.

This is a snippet of our Schnorr Account contract implementation, which uses Schnorr signatures for authentication:

entrypoint
// Load public key from storage
let storage = Storage::init(context);
let public_key = storage.signing_public_key.get_note();
// Load auth witness
let witness: [Field; 64] = get_auth_witness(outer_hash);
let mut signature: [u8; 64] = [0; 64];
for i in 0..64 {
signature[i] = witness[i] as u8;
}

// Verify signature of the payload bytes
let verification = std::schnorr::verify_signature_slice(
public_key.x,
public_key.y,
signature,
outer_hash.to_be_bytes(32)
);
assert(verification == true);
Source code: noir-projects/noir-contracts/contracts/schnorr_account_contract/src/main.nr#L66-L87

Still, different accounts may use different signing schemes, may require multi-factor authentication, or may not even use signing keys and instead rely on other authentication mechanisms. Read how to write an account contract for a full example of how to manage authentication.

Furthermore, and since signatures are fully abstracted, how the key is stored in the contract is abstracted as well and left to the developer of the account contract. In the following section we describe a few ways how an account contract could be architected to store signing keys.

Storing signing keys

Using a private note

Storing the signing public key in a private note makes it accessible from the entrypoint function, which is required to be a private function, and allows for rotating the key when needed. However, keep in mind that reading a private note requires nullifying it to ensure it is up to date, so each transaction you send will destroy and recreate the public key. This has the side effect of enforcing a strict ordering across all transactions, since each transaction will refer the instantiation of the private note from the previous one.

Using an immutable private note

Similar to using a private note, but using an immutable private note removes the need to nullify the note on every read. This generates less nullifiers and commitments per transaction, and does not enforce an order across transactions. However, it does not allow the user to rotate their key should they lose it.

Using shared state

A compromise between the two solutions above is to use shared state. This would not generate additional nullifiers and commitments for each transaction while allowing the user to rotate their key. However, this causes every transaction to now have a time-to-live determined by the frequency of the mutable shared state, as well as imposing restrictions on how fast keys can be rotated due to minimum delays.

Reusing some of the in-protocol keys

It is possible to use some of the key pairs defined in protocol (e.g. incoming viewing keys) as the signing key. Since this key is part of the address preimage (more on this on the privacy master key section), you it can be validated against the account contract address rather than having to store it. However, this approach is not recommended since it reduces the security of the user's account.

Using a separate keystore

Since there are no restrictions on the actions that an account contract may execute for authenticating a transaction (as long as these are all private function executions), the signing public keys can be stored in a separate keystore contract that is checked on every call. This will incur in a higher proving time for each transaction, but has no additional cost in terms of fees, and allows for easier key management in a centralized contract.

Complete address

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

partial_address := poseidon2("az_contract_partial_address_v1", contract_class_id, salted_initialization_hash)
public_keys_hash := poseidon2("az_public_keys_hash", Npk_m, Ivpk_m, Ovpk_m, Tpk_m)
address := poseidon2("az_contract_address_v1", public_keys_hash, partial_address)

Typically, for account contracts the public keys will be non-zero and for non-account contracts zero. An example of a non-account contract which would have some of the keys non-zero is an escrow contract. Escrow contract is a type of contract which on its own is an "owner" of a note meaning that it has a Npk_m registered and the notes contain this Npk_m. Participants in this escrow contract would then somehow get a hold of the escrow's nsk_m and nullify the notes based on the logic of the escrow. An example of an escrow contract is a betting contract. In this scenario, both parties involved in the bet would be aware of the escrow's nsk_m. The escrow would then release the reward only to the party that provides a "proof of winning".

Because of the contract address derivation scheme it is possible to check that a given set of public keys corresponds to a given address just by trying to recompute it. Since this is commonly needed to be done when sending a note to an account whose keys are not yet registered in the key registry contract we coined the term complete address for the collection of:

  1. all the user's public keys,
  2. partial address,
  3. contract address.

Once the complete address is shared with the sender, the sender can check that the address was correctly derived from the public keys and partial address and then send the notes to that address. Because of this it is possible to send a note to an account whose account contract was not yet deployed.

note

Note that since the individual keys can be rotated complete address is used only for non-registered accounts. For registered accounts key registry is always the source of truth.