Skip to content

Latest commit

Β 

History

History
622 lines (481 loc) Β· 22.3 KB

encryption-specs.md

File metadata and controls

622 lines (481 loc) Β· 22.3 KB

Implementing the Secret in Contracts

⚠️ This is a very advanced WIP.

Bootstrap Process

Before the genesis of a new chain, there must be a bootstrap node to generate network-wide secrets to fuel all the privacy features of the chain.

consensus_seed

  • Create a remote attestation proof that the node's Enclave is genuine.
  • Generate inside the Enclave a true random 256 bits seed: consensus_seed.
  • Seal consensus_seed with MRENCLAVE to a local file: $HOME/.secretd/sgx-secrets/consensus_seed.sealed.
// 256 bits
consensus_seed = true_random({ bytes: 32 });

seal({
  key: "MRENCLAVE",
  data: consensus_seed,
  to_file: "$HOME/.secretd/sgx-secrets/consensus_seed.sealed",
});

Key Derivation

TODO reasoning

  • Key Derivation inside the Enclave is done deterministically using HKDF-SHA256 [1][2].
  • The HKDF-SHA256 salt is chosen to be Bitcoin's halving block hash.
hkfd_salt = 0x000000000000000000024bead8df69990852c202db0e0097c1a12ea637d7e96d;
  • Using HKDF-SHA256, hkfd_salt and consensus_seed, derive the following keys:

consensus_seed_exchange_privkey

  • consensus_seed_exchange_privkey: A curve25519 private key. Will be used to derive encryption keys in order to securely share consensus_seed with new nodes in the network.
  • From consensus_seed_exchange_privkey calculate consensus_seed_exchange_pubkey.
consensus_seed_exchange_privkey = hkdf({
  salt: hkfd_salt,
  ikm: consensus_seed.append(uint8(1)),
}); // 256 bits

consensus_seed_exchange_pubkey = calculate_curve25519_pubkey(
  consensus_seed_exchange_privkey
);

consensus_io_exchange_privkey

  • consensus_io_exchange_privkey: A curve25519 curve private key. Will be used to derive encryption keys in order to decrypt transaction inputs and encrypt transaction outputs.
  • From consensus_io_exchange_privkey calculate consensus_io_exchange_pubkey.
consensus_io_exchange_privkey = hkdf({
  salt: hkfd_salt,
  ikm: consensus_seed.append(uint8(2)),
}); // 256 bits

consensus_io_exchange_pubkey = calculate_curve25519_pubkey(
  consensus_io_exchange_privkey
);

consensus_state_ikm

  • consensus_state_ikm: An input keying material (IKM) for HKDF-SHA256 to derive encryption keys for contracts' state.
consensus_state_ikm = hkdf({
  salt: hkfd_salt,
  ikm: consensus_seed.append(uint8(3)),
}); // 256 bits

Bootstrap Process Epilogue

TODO reasoning

  • Seal consensus_seed to disk at "$HOME/.secretd/sgx-secrets/consensus_seed.sealed".
  • Publish to genesis.json:
    • The remote attestation proof that the Enclave is genuine.
    • consensus_seed_exchange_pubkey
    • consensus_io_exchange_pubkey

Node Startup

When a full node resumes its participation in the network, it reads consensus_seed from "$HOME/.secretd/sgx-secrets/consensus_seed.sealed" and again does key derivation as outlined above.

New Node Registration

A new node wants to join the network as a full node.

On the new node

TODO reasoning

  • Verify the remote attestation proof of the bootstrap node from genesis.json.
  • Create a remote attestation proof that the node's Enclave is genuine.
  • Generate inside the node's Enclave a true random curve25519 curve private key: registration_privkey.
  • From registration_privkey calculate registration_pubkey.
  • Send an secretcli tx register auth transaction with the following inputs:
    • The remote attestation proof that the node's Enclave is genuine.
    • registration_pubkey
    • 256 bits true random nonce

On the consensus layer, inside the Enclave of every full node

TODO reasoning

  • Receive the secretcli tx register auth transaction.
  • Verify the remote attestation proof that the new node's Enclave is genuine.

seed_exchange_key

TODO reasoning

  • seed_exchange_key: An AES-128-SIV encryption key. Will be used to send consensus_seed to the new node.
  • AES-128-SIV was chosen to prevent IV misuse by client libraries.
  • seed_exchange_key is derived the following way:
    • seed_exchange_ikm is derived using ECDH (x25519) with consensus_seed_exchange_privkey and registration_pubkey.
    • seed_exchange_key is derived using HKDF-SHA256 from seed_exchange_ikm and nonce.
seed_exchange_ikm = ecdh({
  privkey: consensus_seed_exchange_privkey,
  pubkey: registration_pubkey,
}); // 256 bits

seed_exchange_key = hkdf({
  salt: hkfd_salt,
  ikm: concat(seed_exchange_ikm, nonce),
}); // 256 bits

Sharing consensus_seed with the new node

TODO reasoning

  • The output of the secretcli tx register auth transaction is consensus_seed encrypted with AES-128-SIV, seed_exchange_key as the encryption key, using the public key of the registering node for the AD.
encrypted_consensus_seed = aes_128_siv_encrypt({
  key: seed_exchange_key,
  data: consensus_seed,
  ad: new_node_public_key,
});

return encrypted_consensus_seed;

Back on the new node, inside its Enclave

  • Receive the secretcli tx register auth transaction output (encrypted_consensus_seed).

seed_exchange_key

TODO reasoning

  • seed_exchange_key: An AES-128-SIV encryption key. Will be used to decrypt consensus_seed.

  • seed_exchange_key is derived the following way:

    • seed_exchange_ikm is derived using ECDH (x25519) with consensus_seed_exchange_pubkey (public in genesis.json) and registration_privkey (available only inside the new node's Enclave).

    • seed_exchange_key is derived using HKDF-SHA256 with seed_exchange_ikm and nonce.

seed_exchange_ikm = ecdh({
  privkey: registration_privkey,
  pubkey: consensus_seed_exchange_pubkey, // from genesis.json
}); // 256 bits

seed_exchange_key = hkdf({
  salt: hkfd_salt,
  ikm: concat(seed_exchange_ikm, nonce),
}); // 256 bits

Decrypting encrypted_consensus_seed

TODO reasoning

  • encrypted_consensus_seed is encrypted with AES-128-SIV, seed_exchange_key as the encryption key and the public key of the registering node as the ad as the decryption additional data.
  • The new node now has all of these^ parameters inside its Enclave, so it's able to decrypt consensus_seed from encrypted_consensus_seed.
  • Seal consensus_seed to disk at "$HOME/.secretd/sgx-secrets/consensus_seed.sealed".
consensus_seed = aes_128_siv_decrypt({
  key: seed_exchange_key,
  data: encrypted_consensus_seed,
  ad: new_node_public_key,
});

seal({
  key: "MRENCLAVE",
  data: consensus_seed,
  to_file: "$HOME/.secretd/sgx-secrets/consensus_seed.sealed",
});

New Node Registration Epilogue

TODO reasoning

  • The new node can now do key derivation to get all the required network-wide secrets in order participate in blocks execution and validation.
  • After a machine/process reboot, the node can go through the node startup process again.

Contracts State Encryption

TODO reasoning

  • While executing a function call inside the Enclave as part of a transaction, the contract code can call write_db(field_name, value) and read_db(field_name).
  • Contracts' state is stored on-chain inside a key-value store, thus the field_name must remain constant between calls.
  • encryption_key is derived using HKDF-SHA256 from:
    • consensus_state_ikm
    • field_name
    • contact_key

contract_key

  • contract_key is a concatenation of two values: signer_id || authenticated_contract_key.
  • When a contract is deployed (i.e., on contract init), contract_key is generated inside of the Enclave as follows:
signer_id = sha256(concat(msg_sender, block_height));

authentication_key = hkdf({
  salt: hkfd_salt,
  info: "contract_key",
  ikm: concat(consensus_state_ikm, signer_id),
});

authenticated_contract_key = hmac_sha256({
  key: authentication_key,
  data: code_hash,
});

contract_key = concat(signer_id, authenticated_contract_key);
  • Every time a contract execution is called, contract_key should be sent to the Enclave.
  • In the Enclave, the following verification needs to happen:
signer_id = contract_key.slice(0, 32);
expected_contract_key = contract_key.slice(32, 64);

authentication_key = hkdf({
  salt: hkfd_salt,
  info: "contract_key",
  ikm: concat(consensus_state_ikm, signer_id),
});

calculated_contract_key = hmac_sha256({
  key: authentication_key,
  data: code_hash,
});

assert(calculated_contract_key == expected_contract_key);

write_db(field_name, value)

encryption_key = hkdf({
  salt: hkfd_salt,
  ikm: concat(consensus_state_ikm, field_name, contract_key),
});

encrypted_field_name = aes_128_siv_encrypt({
  key: encryption_key,
  data: field_name,
});

current_state_ciphertext = internal_read_db(encrypted_field_name);

if (current_state_ciphertext == null) {
  // field_name doesn't yet initialized in state
  ad = sha256(encrypted_field_name);
} else {
  // read previous_ad, verify it, calculate new ad
  previous_ad = current_state_ciphertext.slice(0, 32); // first 32 bytes/256 bits
  current_state_ciphertext = current_state_ciphertext.slice(32); // skip first 32 bytes

  aes_128_siv_decrypt({
    key: encryption_key,
    data: current_state_ciphertext,
    ad: previous_ad,
  }); // just to authenticate previous_ad
  ad = sha256(previous_ad);
}

new_state_ciphertext = aes_128_siv_encrypt({
  key: encryption_key,
  data: value,
  ad: ad,
});

new_state = concat(ad, new_state_ciphertext);

internal_write_db(encrypted_field_name, new_state);

read_db(field_name)

encryption_key = hkdf({
  salt: hkfd_salt,
  ikm: concat(consensus_state_ikm, field_name, contract_key),
});

encrypted_field_name = aes_128_siv_encrypt({
  key: encryption_key,
  data: field_name,
});

current_state_ciphertext = internal_read_db(encrypted_field_name);

if (current_state_ciphertext == null) {
  // field_name doesn't yet initialized in state
  return null;
}

// read ad, verify it
ad = current_state_ciphertext.slice(0, 32); // first 32 bytes/256 bits
current_state_ciphertext = current_state_ciphertext.slice(32); // skip first 32 bytes
current_state_plaintext = aes_128_siv_decrypt({
  key: encryption_key,
  data: current_state_ciphertext,
  ad: ad,
});

return current_state_plaintext;

Transaction Encryption

TODO reasoning

  • tx_encryption_key: An AES-128-SIV encryption key. Will be used to encrypt tx inputs and decrypt tx outpus.
    • tx_encryption_ikm is derived using ECDH (x25519) with consensus_io_exchange_pubkey and tx_sender_wallet_privkey (on the sender's side).
    • tx_encryption_ikm is derived using ECDH (x25519) with consensus_io_exchange_privkey and tx_sender_wallet_pubkey (inside the Enclave of every full node).
  • tx_encryption_key is derived using HKDF-SHA256 with tx_encryption_ikm and a random number nonce. This is to prevent using the same key for the same tx sender multiple times.

Input

On the transaction sender

tx_encryption_ikm = ecdh({
  privkey: tx_sender_wallet_privkey,
  pubkey: consensus_io_exchange_pubkey, // from genesis.json
}); // 256 bits

nonce = true_random({ bytes: 32 });

tx_encryption_key = hkdf({
  salt: hkfd_salt,
  ikm: concat(tx_encryption_ikm, nonce),
}); // 256 bits

ad = concat(nonce, tx_sender_wallet_pubkey);

encrypted_msg = aes_128_siv_encrypt({
  key: tx_encryption_key,
  data: msg,
  ad: ad,
});

tx_input = concat(ad, encrypted_msg);

On the consensus layer, inside the Enclave of every full node

nonce = tx_input.slice(0, 32); // 32 bytes
tx_sender_wallet_pubkey = tx_input.slice(32, 32); // 32 bytes, compressed curve25519 public key
encrypted_msg = tx_input.slice(64);

tx_encryption_ikm = ecdh({
  privkey: consensus_io_exchange_privkey,
  pubkey: tx_sender_wallet_pubkey,
}); // 256 bits

tx_encryption_key = hkdf({
  salt: hkfd_salt,
  ikm: concat(tx_encryption_ikm, nonce),
}); // 256 bits

msg = aes_128_siv_decrypt({
  key: tx_encryption_key,
  data: encrypted_msg,
});

Output

  • The output must be a valid JSON object, as it is passed to multiple mechanisms for final processing:
    • Logs are treated as Tendermint events
    • Messages can be callbacks to another contract call
    • Messages can also instruct to send funds from the contract's wallet
    • There's a data section which is free form bytes to be inerperted by the client (or dApp)
    • And there's also an error section
  • Therefore the output must be partialy-encrypted.
  • An example output for an execution:
    {
      "ok": {
        "messages": [
          {
            "type": "Send",
            "to": "...",
            "amount": "..."
          },
          {
            "type": "Contract",
            "msg": "{\"banana\":1,\"papaya\":2}", // need to encrypt this value
            "contract_addr": "aaa",
            "send": { "amount": 100, "denom": "uscrt" }
          },
          {
            "type": "Contract",
            "msg": "{\"water\":1,\"fire\":2}", // need to encrypt this value
            "contract_addr": "bbb",
            "send": { "amount": 0, "denom": "uscrt" }
          }
        ],
        "log": [
          {
            "key": "action", // need to encrypt this value
            "value": "transfer" // need to encrypt this value
          },
          {
            "key": "sender", // need to encrypt this value
            "value": "secret1v9tna8rkemndl7cd4ahru9t7ewa7kdq8d4zlr5" // need to encrypt this value
          },
          {
            "key": "recipient", // need to encrypt this value
            "value": "secret1f395p0gg67mmfd5zcqvpnp9cxnu0hg6rp5vqd4" // need to encrypt this value
          }
        ],
        "data": "bla bla" // need to encrypt this value
      }
    }
  • Notice on a Contract message, the msg value should be the same msg as in our tx_input, so we need to prepend the nonce and tx_sender_wallet_pubkey just like we did on the tx sender above.
  • For the rest of the encrypted outputs we only need to send the ciphertext, as the tx sender can get consensus_io_exchange_prubkey from genesis.json and nonce from the tx_input that is attached to the tx_output.
  • An example output with an error:
    {
      "err": "{\"watermelon\":6,\"coffee\":5}" // need to encrypt this value
    }
  • An example output for a query:
    {
      "ok": "{\"answer\":42}" // need to encrypt this value
    }

On the consensus layer, inside the Enclave of every full node

// already have from tx_input:
// - tx_encryption_key
// - nonce

if (typeof output["err"] == "string") {
  encrypted_err = aes_128_siv_encrypt({
    key: tx_encryption_key,
    data: output["err"],
  });
  output["err"] = base64_encode(encrypted_err); // needs to be a JSON string
} else if (typeof output["ok"] == "string") {
  // query
  // output["ok"] is handled the same way as output["err"]...
  encrypted_query_result = aes_128_siv_encrypt({
    key: tx_encryption_key,
    data: output["ok"],
  });
  output["ok"] = base64_encode(encrypted_query_result); // needs to be a JSON string
} else if (typeof output["ok"] == "object") {
  // execute
  for (m in output["ok"]["messages"]) {
    if (m["type"] == "Contract") {
      encrypted_msg = aes_128_siv_encrypt({
        key: tx_encryption_key,
        data: m["msg"],
      });

      // base64_encode because needs to be a string
      // also turns into a tx_input so we also need to prepend nonce and tx_sender_wallet_pubkey
      m["msg"] = base64_encode(
        concat(nonce, tx_sender_wallet_pubkey, encrypted_msg)
      );
    }
  }

  for (l in output["ok"]["log"]) {
    // l["key"] is handled the same way as output["err"]...
    encrypted_log_key_name = aes_128_siv_encrypt({
      key: tx_encryption_key,
      data: l["key"],
    });
    l["key"] = base64_encode(encrypted_log_key_name); // needs to be a JSON string

    // l["value"] is handled the same way as output["err"]...
    encrypted_log_value = aes_128_siv_encrypt({
      key: tx_encryption_key,
      data: l["value"],
    });
    l["value"] = base64_encode(encrypted_log_value); // needs to be a JSON string
  }

  // output["ok"]["data"] is handled the same way as output["err"]...
  encrypted_output_data = aes_128_siv_encrypt({
    key: tx_encryption_key,
    data: output["ok"]["data"],
  });
  output["ok"]["data"] = base64_encode(encrypted_output_data); // needs to be a JSON string
}

return output;

Back on the transaction sender

  • The transaction output is written to the chain
  • Only the wallet with the right tx_sender_wallet_privkey can derive tx_encryption_key, so for everyone else it will just be encrypted.
  • Every encrypted value can be decrypted the following way:
// output["err"]
// output["ok"]["data"]
// output["ok"]["log"][i]["key"]
// output["ok"]["log"][i]["value"]
// output["ok"] if input is a query

encrypted_bytes = base64_encode(encrypted_output);

aes_128_siv_decrypt({
  key: tx_encryption_key,
  data: encrypted_bytes,
});
  • For output["ok"]["messages"][i]["type"] == "Contract", output["ok"]["messages"][i]["msg"] will be decrypted in this manner by the consensus layer when it handles the contract callback.

Blockchain Upgrades

TODO

Theoretical Attacks

TODO add more

Deanonymizing with ciphertext byte count

No encryption padding, so a value of e.g. "yes" or "no" can be deanonymized by its byte count.

Two contracts with the same contract_key could deanonymize each other's states

If an attacker can create a contract with the same contract_key as another contract, the state of the original contract can potentially be deanonymized.

For example, An original contract with a permissioned getter, such that only whitelisted addresses can query the getter. In the malicious contract the attacker can set themselves as the owner and ask the malicious contract to decrypt the state of the original contract via that permissioned getter.

Tx Replay attacks

After a contract runs on the chain, an attaker can sync up a node up to a specific block in the chain, and then call into the enclave with the same authenticated user inputs that were given to the enclave on-chain, but out-of-order, or omit selected messages. A contract that does not anticipate or protect against this might end up deanonimizing the information provided by users. for example, in a naive voting contract (or other personal data collection algorithm), we can deanonimize a voter by re-running the vote without the target's request, and analyze thedifference in final results.

Partial storage rollback during contract runtime

Our current schema can verify that when reading from a field in storage, the value received from the host has been written by the same contract instance to the same field in storage. BUT we can not (yet) verify that the value is the most recent value that wsa stored there. This means that a malicious host can (offline) run a transaction, and then selectively provide outdated values for some fields of the storage. In the worst case, this can cause a contract to expose old secrets with new permissions, or new secrets with old permissions. The contract can protect against this by either (e.g.) making sure that pieces of information that have to be synced with each other are saved under the same field (so they are never observed as desynchronised) or (e.g.) somehow verify their validity when reading them from two separate fields of storage.

Tx outputs can leak data

E.g. a dev writes a contract with 2 functions, the first one always outputs 3 events and the second one always outputs 4 events. By counting the number of output events an attacker can know which funcation was invoked. Also applies with deposits, callbacks and transfers.