Skip to content

Commit

Permalink
docs(yellow-paper): Update keys and addresses (#3707)
Browse files Browse the repository at this point in the history
Keeps the specification that Mike wrote (removing app-siloed incoming
viewing keys), changes private message delivery, and adds new sections
on precompiles, registry, sending guidelines, diversified and stealth
accounts, batched and unconstrained calls, etc.
  • Loading branch information
spalladino authored Jan 2, 2024
1 parent 51152ba commit 56992ae
Show file tree
Hide file tree
Showing 17 changed files with 579 additions and 355 deletions.
8 changes: 0 additions & 8 deletions yellow-paper/docs/addresses-and-keys/_category_.json

This file was deleted.

42 changes: 42 additions & 0 deletions yellow-paper/docs/addresses-and-keys/diversified-and-stealth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
title: Diversified and Stealth Accounts
sidebar_position: 4
---

The [keys specification](./specification.md) describes derivation mechanisms for diversified and stealth public keys. However, the protocol requires users to interact with addresses.

## Computing Addresses

To support diversified and stealth accounts, a user may compute the deterministic address for a given account contract that is deployed using the diversified or stealth public key, so a sender can interact with the resulting address even before the account contract is deployed.

When the user wants to access the notes that were sent to the diversified or stealth address, they can deploy the contract at their address, and control it privately from their main account.

## Account Contract Pseudocode

As an example implementation, account contracts for diversified and stealth accounts can be designed to require no private constructor or state, and delegate entrypoint access control to their master address.

```
contract DiversifiedAccount
private fn entrypoint(payload: action[])
assert msg_sender == get_owner_address()
execute(payload)
private fn is_valid(message_hash: Field)
return get_owner_address().is_valid(message_hash)
internal private get_owner_address()
let address_preimage = pxe.get_address_preimage(this)
assert hash(address_preimage) == this
return address_preimage.deployer_address
```

<!-- TODO: The implementation above hinges on whether `deployer_address` for a contract is the actual deployer, or is the ContractDeployer contract. -->

Given the contract does not require initialization since it has no constructor, it can be used by its owner without being actually deployed, which reduces the setup cost.

<!-- TODO: The above requires that we implement "using a contract without deploying it if it has no constructor", or "constructor abstraction", both of which are a bit controversial. -->

## Discarded Approaches

An alternative approach was to introduce a new type of call, a diversified call, that would allow the caller to impersonate any address they can derive from their own, for an enshrined derivation mechanism. Account contracts could use this opcode, as opposed to a regular call, to issue calls on behalf on their diversified and stealth addresses. However, this approach failed to account for calls made back to the account contracts, in particular authwit checks. It also required protocol changes, introducing a new type of call which could be difficult to reason about, and increased attack surface. The only benefit over the approach chosen is that it would require one less extra function call to hop from the user's main account contract to the diversified or stealth one.
18 changes: 18 additions & 0 deletions yellow-paper/docs/addresses-and-keys/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
title: Addresses and Keys
sidebar_position: 2
---

Aztec has no concept of externally-owned accounts. Every address is meant to identify a smart contract in the network. Addresses are then a commitment to a contract class, a list of constructor arguments, and a set of keys.

Keys in Aztec are used both for authorization and privacy. Authorization keys are managed by account contracts, and not mandated by the protocol. Each account contract may use different authorization keys, if at all, with different signing mechanisms.

Privacy keys are used for note encryption, tagging, and nullifying. These are also not enforced by the protocol. However, for facilitating composability, the protocol enshrines a set of well-known encryption and tagging mechanisms, that can be leveraged by applications as they interact with accounts.

The [specification](./specification.md) covers the main requirements for addresses and keys, along with their specification and derivation mechanisms, while the [precompiles](./precompiles.md) section describes well-known contract addresses, with implementations defined by the protocol, used for note encryption and tagging.

Last, the [diversified and stealth accounts](./diversified-and-stealth.md) sections describe application-level recommendations for diversified and stealth accounts.

import DocCardList from '@theme/DocCardList';

<DocCardList />
164 changes: 164 additions & 0 deletions yellow-paper/docs/addresses-and-keys/precompiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
---
title: Precompiles
sidebar_position: 2
---

Precompiled contracts, which borrow their name from Ethereum's, are contracts not deployed by users but defined at the protocol level. These contracts and their classes are assigned well-known low-number addresses and identifiers, and their implementation is subject to change via protocol upgrades. Precompiled contracts in Aztec are implemented as a set of circuits, one for each function they expose, like user-defined private contracts. Precompiles may make use of the local PXE oracle. Note that, unlike user-defined contracts, the address of a precompiled contract instance and the identifier of its class both have no known preimage.

Rationale for precompiled contracts is to provide a set of vetted primitives for note encryption and tagging that applications can use safely. These primitives are guaranteed to be always satisfiable when called with valid arguments. This allows account contracts to choose their preferred method of encryption and tagging from any primitive in this set, and application contracts to call into them without the risk of calling into a untrusted code, which could potentially halt the execution flow via an unsatisfiable constrain. Furthermore, by exposing these primitives in a reserved set of well-known addresses, applications can be forward-compatible and incorporate new encryption and tagging methods as accounts opt into them.

## Constants

- `ENCRYPTION_BATCH_SIZES=[4, 16, 32]`: Defines what max batch sizes are supported in precompiled encryption methods.
- `ENCRYPTION_PRECOMPILE_ADDRESS_RANGE=0x00..0xFFFF`: Defines the range of addresses reserved for precompiles used for encryption and tagging.
- `MAX_PLAINTEXT_LENGTH`: Defines the maximum length of a plaintext to encrypt.
- `MAX_CYPHERTEXT_LENGTH`: Defines the maximum length of a returned encrypted cyphertext.
- `MAX_TAGGED_CYPHERTEXT_LENGTH`: Defines the maximum length of a returned encrypted cyphertext prefixed with a note tag.

## Encryption and tagging precompiles

All precompiles in the address range `ENCRYPTION_PRECOMPILE_ADDRESS_RANGE` are reserved for encryption and tagging. Application contracts can expected to call into these contracts with note plaintext, recipients, and public keys. To facilitate forward compatibility, all unassigned addresses within the range expose the functions below as no-ops, meaning that no actions will be executed when calling into them.

All functions in these precompiles accept a `PublicKeys` struct which contains the user advertised public keys. The structure of each of the public keys included can change from one encryption method to another, with the exception of the `nullifier_key` which is always restricted to a single field element. For forward compatibility, the precompiles interface accepts a hash of the public keys, which can be expanded within each method via an oracle call.

```
struct PublicKeys:
nullifier_key: Field
incoming_encryption_key: PublicKey
outgoing_encryption_key: PublicKey
incoming_internal_encryption_key: PublicKey
tagging_key: PublicKey
```

To identify which public key to use in the encryption, precompiles also accept an enum:

```
enum EncryptionType:
incoming = 1
outgoing = 2
incoming_internal = 3
```

Precompiles expose the following private functions:

```
validate_keys(public_keys_hash: Field): bool
```

Returns true if the set of public keys represented by `public_keys` is valid for this encryption and tagging mechanism. The precompile must guarantee that any of its methods must succeed if called with a set of public keys deemed as valid. This method returns `false` for undefined precompiles.

```
encrypt(public_keys_hash: Field, encryption_type: EncryptionType, recipient: AztecAddress, plaintext: Field[MAX_PLAINTEXT_LENGTH]): Field[MAX_CYPHERTEXT_LENGTH]
```

Encrypts the given plaintext using the provided public keys, and returns the encrypted cyphertext.

```
encrypt_and_tag(public_keys_hash: Field, encryption_type: EncryptionType, recipient: AztecAddress, plaintext: Field[MAX_PLAINTEXT_LENGTH]): Field[MAX_TAGGED_CYPHERTEXT_LENGTH]
```

Encrypts and tags the given plaintext using the provided public keys, and returns the encrypted note prefixed with its tag for note discovery.

```
encrypt_and_broadcast(public_keys_hash: Field, encryption_type: EncryptionType, recipient: AztecAddress, plaintext: Field[MAX_PLAINTEXT_LENGTH]): Field[MAX_TAGGED_CYPHERTEXT_LENGTH]
```

Encrypts and tags the given plaintext using the provided public keys, broadcasts them as an event, and returns the encrypted note prefixed with its tag for note discovery. This functions should be invoked via a [delegate call](../calls/delegate-calls.md), so that the broadcasted event is emitted as if it were from the caller contract.

```
encrypt<N>([call_context: CallContext, public_keys_hash: Field, encryption_type: EncryptionType, recipient: AztecAddress, plaintext: Field[MAX_PLAINTEXT_LENGTH] ][N]): Field[MAX_CYPHERTEXT_LENGTH][N]
encrypt_and_tag<N>([call_context: CallContext, public_keys_hash: Field, encryption_type: EncryptionType, recipient: AztecAddress, plaintext: Field[MAX_PLAINTEXT_LENGTH] ][N]): Field[MAX_TAGGED_CYPHERTEXT_LENGTH][N]
encrypt_and_broadcast<N>([call_context: CallContext, public_keys_hash: Field, encryption_type: EncryptionType, recipient: AztecAddress, plaintext: Field[MAX_PLAINTEXT_LENGTH] ][N]): Field[MAX_TAGGED_CYPHERTEXT_LENGTH][N]
```

Batched versions of the methods above, which accept an array of `N` tuples of public keys, recipient, and plaintext to encrypt in batch. Precompiles expose instances of this method for multiple values of `N` as defined by `ENCRYPTION_BATCH_SIZES`. Values in the batch with zeroes are skipped. These functions are intended to be used in [batched calls](../calls/batched-calls.md).

```
decrypt(public_keys_hash: Field, encryption_type: EncryptionType, owner: AztecAddress, cyphertext: Field[MAX_CYPHERTEXT_LENGTH]): Field[MAX_PLAINTEXT_LENGTH]
```

Decrypts the given cyphertext, encrypted for the provided owner. Instead of receiving the decryption key, this method triggers an oracle call to fetch the private decryption key directly from the local PXE and validates it against the supplied public key, in order to avoid leaking a user secret to untrusted application code. This method is intended for provable decryption use cases.

## Encryption strategies

List of encryption strategies implemented by precompiles:

### AES128

Uses AES128 for encryption, by generating an AES128 symmetric key and an IV from a shared secret derived from the recipient's public key and an ephemeral keypair. Requires that the recipient's keys are points in the Grumpkin curve. The output of the encryption is the concatenation of the encrypted cyphertext and the ephemeral public key.

Pseudocode for the encryption process:

```
encrypt(plaintext, recipient_public_key):
ephemeral_private_key, ephemeral_public_key = grumpkin_random_keypair()
shared_secret = recipient_public_key * ephemeral_private_key
[aes_key, aes_iv] = sha256(shared_secret ++ [0x01])
return ephemeral_public_key ++ aes_encrypt(aes_key, aes_iv, plaintext)
```

Pseudocode for the decryption process:

```
decrypt(cyphertext, recipient_private_key):
ephemeral_public_key = cyphertext[0:64]
shared_secret = ephemeral_public_key * recipient_private_key
[aes_key, aes_iv] = sha256(shared_secret ++ [0x01])
return aes_decrypt(aes_key, aes_iv, cyphertext[64:])
```

<!-- TODO: Why append the [1] at the end of the shared secret? Also, do we want to keep using sha, or should we use a snark-friendlier hash here? -->

## Note tagging strategies

List of note tagging strategies implemented by precompiles:

### Trial decryption

Trial decryption relies on the recipient to brute-force trial-decrypting every note emitted by the chain. Every note is attempted to be decrypted with the associated decryption scheme. If decryption is successful, then the note is added to the local database. This requires no note tags to be emitted along with a note.

In AES encryption, the plaintext is prefixed with the first 8 bytes of the IV. Decryption is deemed successful if the first 8 bytes of the decrypted plaintext matches the first 8 bytes of the IV derived from the shared secret.

This is the cheapest approach in terms of calldata cost, and the simplest to implement, but puts a significant burden on the user. Should not be used except for accounts tied to users running full nodes.

### Delegated trial decryption

Delegated trial decryption relies on a tag added to each note, generated used the recipient's tagging public key. The holder of the corresponding tagging private key can trial-decrypt each tag, and if decryption is successful, proceed to decrypt the contents of the note using the associated decryption scheme.

This allows a user to share their tagging private key with a trusted service provider, who then proceeds to trial decrypt all possible note tags on their behalf. This scheme is simple for the user, but requires trust on a third party.

<!-- TODO: How should the tag be generated here? Tags should be unique, and tags encrypted with the same pubkey should not be linkable to each other without the tagging private key. Can we use a similar method than the IV check in trial-decryption? -->

### Tag hopping

Tag hopping relies on establishing a one-time shared secret through a handshake between each sender-recipient pair, advertise the handshake through a trial-decrypted brute-forced channel, and then generate tags by combining the shared secret and an incremental counter. Recipients need to trial-decrypt events emitted by a canonical `Handshake` contract to detect new channels established with them, and then scan for the next tag for each open channel. Note that the handshake contract leaks whenever a new shared secret has been established, but the participants of the handshake are kept hidden.

This method requires the recipient to be continuously trial-decrypting the handshake channel, and then scanning for a number of tags equivalent to the number of handshakes they had received. While this can get to too large amounts for particularly active addresses, it is still far more efficient than trial decryption.

When Alice wants to send a message to Bob for the first time:

1. Alice creates a note, and calls into Bob's encryption and tagging precompile.
2. The precompile makes an oracle call to `getSharedSecret(Alice, Bob)`.
3. Alice's PXE looks up the shared secret which doesn't exist since this is their first interaction.
4. Alice's PXE generates a random shared secret, and stores it associated Bob along with `counter=1`.
5. The precompile makes a call to the `Handshake` contract that emits the shared secret, encrypted for Bob and optionally Alice.
6. The precompile computes `new_tag = hash(alice, bob, secret, counter)`, emits it as a nullifier, and prepends it to the note cyphertext before broadcasting it.

For all subsequent messages:

1. Alice creates a note, and calls into Bob's encryption and tagging precompile.
2. The precompile makes an oracle call to `getSharedSecret(Alice, Bob)`.
3. Alice's PXE looks up the shared secret and returns it, along with the current value for `counter`, and locally increments `counter`.
4. The precompile computes `previous_tag = hash(alice, bob, secret, counter)`, and performs a merkle membership proof for it in the nullifier tree. This ensures that tags are incremental and cannot be skipped.
5. The precompile computes `new_tag = hash(alice, bob, secret, counter + 1)`, emits it as a nullifier, and prepends it to the note cyphertext before broadcasting it.

## Defined precompiles

List of precompiles defined by the protocol:

| Address | Encryption | Note Tagging | Comments |
|---------|------------|--------------|----------|
| 0x01 | Noop | Noop | Used by accounts to explicitly signal that they cannot receive encrypted payloads. Validation method returns `true` only for an empty list of public keys. All other methods return empty. |
| 0x02 | AES128 | Trial decryption | |
| 0x03 | AES128 | Delegated trial decryption | |
| 0x04 | AES128 | Tag hopping | |
Loading

0 comments on commit 56992ae

Please sign in to comment.