Skip to main content

Key Derivation (X25519)

Sapphire.deriveSymmetricKey implements X25519 Diffie-Hellman key exchange. Given a public key and a private key from opposite ends of the exchange, it returns a shared 32-byte symmetric key. Combine this with Symmetric Encryption to build encrypt-to-user or encrypt-to-contract patterns.

Signature

function deriveSymmetricKey(bytes32 publicKey, bytes32 privateKey)
internal view returns (bytes32);
  • publicKey — the other party's X25519 public key.
  • privateKey — your X25519 private key.

Returns a 32-byte symmetric key that both parties can compute independently:

  • Alice computes derive(Bob.publicKey, Alice.privateKey)
  • Bob computes derive(Alice.publicKey, Bob.privateKey)
  • Both get the same result

Basic usage

import "@oasisprotocol/sapphire-contracts/contracts/Sapphire.sol";

contract EncryptToUser {
function encryptFor(
bytes32 userPublicKey,
bytes32 myPrivateKey,
bytes memory plaintext
) external view returns (bytes32 nonce, bytes memory ciphertext) {
bytes32 sharedKey = Sapphire.deriveSymmetricKey(userPublicKey, myPrivateKey);
nonce = bytes32(Sapphire.randomBytes(32, ""));
ciphertext = Sapphire.encrypt(sharedKey, nonce, plaintext, "");
}
}

Only the intended recipient can compute sharedKey (they have myPublicKey and their own private key). Nobody else, including the enclave, retains that key.

Full encrypt-to-user example

contract PrivateMessages {
// Users register their X25519 public key
mapping(address => bytes32) public userPublicKeys;
mapping(address => bytes[]) private inboxes;

function registerKey(bytes32 pk) external {
userPublicKeys[msg.sender] = pk;
}

function sendMessage(address to, bytes32 senderPrivateKey, bytes calldata message) external {
bytes32 sharedKey = Sapphire.deriveSymmetricKey(
userPublicKeys[to],
senderPrivateKey
);
bytes32 nonce = bytes32(Sapphire.randomBytes(32, ""));
bytes memory ct = Sapphire.encrypt(sharedKey, nonce, message, "PrivateMessages");

// Store envelope: nonce || ciphertext, plus sender's public key so recipient can decrypt
bytes32 senderPk = derivePublicFromPrivate(senderPrivateKey); // custom helper
inboxes[to].push(abi.encodePacked(senderPk, nonce, ct));
}
}

Off-chain, the recipient (using their own X25519 private key) computes the same shared secret and decrypts.

When to use X25519 vs storing a symmetric key

Use X25519 (deriveSymmetricKey) when:

  • You need to encrypt for a specific recipient who possesses their own private key.
  • You want to send messages between users where the contract only relays ciphertext.
  • Off-chain services need to decrypt.

Use a stored symmetric key when:

  • The contract is the sole holder and user of the key.
  • Data will be re-decrypted many times from the same key.
  • No off-chain recipients are involved.

Ephemeral vs persistent keys

Ephemeral X25519 keys — generated per session, thrown away. Provides forward secrecy: leaking today's session key does not compromise past or future sessions. This is how OXN's own encrypted transaction envelope works.

Persistent X25519 keys — stored in contract storage or in the user's wallet. Simpler, but a leaked key exposes all historical ciphertexts encrypted to it. Use persistent keys when the user identity is meant to be stable.

Common patterns

Handshake pattern:

function beginSession(bytes32 clientEphemeralPk) external view returns (bytes32 shared) {
return Sapphire.deriveSymmetricKey(
clientEphemeralPk,
contractPrivateKey
);
}

Encrypt-then-emit (event):

event Message(address indexed to, bytes32 nonce, bytes ciphertext);

function sendMessage(...) external {
bytes32 shared = Sapphire.deriveSymmetricKey(userPublicKeys[to], msg.sender);
// ...
emit Message(to, nonce, ciphertext);
}

Remember: on OXN, event contents are encrypted per-caller-session. The message goes into the block, but the topic and data are opaque to observers.

Next steps