For AI agents: Documentation index at /llms.txt

Skip to content

Encryption with VetKeys

VetKeys enable canisters to derive cryptographic key material on demand so that clients can encrypt and decrypt data without the canister ever seeing the raw key. This guide covers the complete flow: exposing vetKD endpoints in a canister, generating a transport key pair on the frontend, and using the derived key for symmetric encryption. It also covers higher-level patterns: the EncryptedMaps abstraction for encrypted key-value storage, and identity-based encryption (IBE) for sending encrypted messages to a principal.

For background on how the vetKD protocol works, see VetKeys.

Prerequisites

Add the following to Cargo.toml:

[dependencies]
candid = "0.10"
ic-cdk = "0.19"
ic-vetkeys = "0.6"
ic-stable-structures = "0.7"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"

Frontend (TypeScript):

Terminal window
npm install @dfinity/vetkeys@0.4.0

Step 1: Expose vetKD endpoints in the backend canister

The backend canister wraps the management canister’s vetkd_derive_key and vetkd_public_key methods and enforces per-caller key isolation. The context passed to both API methods encodes the domain separator and the caller’s principal, so each caller’s keys are cryptographically separate and only that caller can retrieve them.

import Array "mo:core/Array";
import Blob "mo:core/Blob";
import Nat8 "mo:core/Nat8";
import Principal "mo:core/Principal";
import Text "mo:core/Text";
persistent actor {
type VetKdCurve = { #bls12_381_g2 };
type VetKdKeyId = {
curve : VetKdCurve;
name : Text;
};
type VetKdPublicKeyRequest = {
canister_id : ?Principal;
context : Blob;
key_id : VetKdKeyId;
};
type VetKdPublicKeyResponse = {
public_key : Blob;
};
type VetKdDeriveKeyRequest = {
input : Blob;
context : Blob;
transport_public_key : Blob;
key_id : VetKdKeyId;
};
type VetKdDeriveKeyResponse = {
encrypted_key : Blob;
};
let managementCanister : actor {
vetkd_public_key : VetKdPublicKeyRequest -> async VetKdPublicKeyResponse;
vetkd_derive_key : VetKdDeriveKeyRequest -> async VetKdDeriveKeyResponse;
} = actor "aaaaa-aa";
let domainSeparator : [Nat8] = Blob.toArray(Text.encodeUtf8("my_app_v1"));
// Encodes domain separator + caller principal so each caller's keys are isolated.
func callerContext(caller : Principal) : Blob {
Blob.fromArray(
Array.flatten([
[Nat8.fromNat(domainSeparator.size())],
domainSeparator,
Blob.toArray(Principal.toBlob(caller)),
])
)
};
func keyId() : VetKdKeyId {
{ curve = #bls12_381_g2; name = "test_key_1" }
// Use "key_1" for production
};
public shared ({ caller }) func getPublicKey() : async Blob {
let response = await managementCanister.vetkd_public_key({
canister_id = null;
context = callerContext(caller);
key_id = keyId();
});
response.public_key
};
public shared ({ caller }) func getEncryptedVetKey(
input : Blob,
transportPublicKey : Blob,
) : async Blob {
// test_key_1 costs ~10B cycles; key_1 costs ~26B cycles
let response = await (with cycles = 10_000_000_000) managementCanister.vetkd_derive_key({
input;
context = callerContext(caller);
transport_public_key = transportPublicKey;
key_id = keyId();
});
response.encrypted_key
};
};

Key decisions:

  • Context: encodes the domain separator (my_app_v1) plus the caller’s principal. This makes every caller’s keys cryptographically separate; a key derived for one principal cannot be decrypted by another. Both getPublicKey and getEncryptedVetKey must use the same context so that decryptAndVerify succeeds on the frontend.
  • Input: an additional identifier within the caller’s key space (a document ID, a room ID, or an empty vector for a single per-user key). Different inputs yield different keys; the same input always yields the same key.
  • Caller capture before await: always read caller before any await in an update call.

Step 2: Generate a transport key pair on the frontend

The transport key pair is ephemeral. Generate it fresh for each session or each key request.

import { TransportSecretKey } from "@dfinity/vetkeys";
const transportSecretKey = TransportSecretKey.random();
const transportPublicKey = transportSecretKey.publicKeyBytes();

Pass transportPublicKey to the canister when requesting a derived key.

Step 3: Retrieve and decrypt the vetKey

import {
TransportSecretKey,
DerivedPublicKey,
EncryptedVetKey,
} from "@dfinity/vetkeys";
// An additional identifier within the caller's key space.
// Use an empty vector for a single per-user key, or a document/room ID for multiple.
const input = new Uint8Array(0);
const [encryptedKeyBytes, verificationKeyBytes] = await Promise.all([
backendActor.get_encrypted_vetkey(input, transportPublicKey),
backendActor.get_public_key(),
]);
const verificationKey = DerivedPublicKey.deserialize(
new Uint8Array(verificationKeyBytes),
);
const encryptedVetKey = EncryptedVetKey.deserialize(
new Uint8Array(encryptedKeyBytes),
);
// Verify and decrypt: throws if the key is malformed or was tampered with
const vetKey = encryptedVetKey.decryptAndVerify(
transportSecretKey,
verificationKey,
input,
);

Step 4: Derive a symmetric key and encrypt data

The raw vetKey is not used directly as an AES key. Use toDerivedKeyMaterial() to derive a symmetric key from it.

// Derive a 256-bit AES-GCM key
const aesKeyMaterial = vetKey.toDerivedKeyMaterial();
const aesKey = await crypto.subtle.importKey(
"raw",
aesKeyMaterial.data.slice(0, 32),
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"],
);
// Encrypt
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
aesKey,
new TextEncoder().encode("secret message"),
);
// Store ciphertext (and iv) in the canister; never store the key
// Decrypt
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
aesKey,
ciphertext,
);

Store only the ciphertext and IV in the canister; the raw key exists only in the client’s memory for the duration of the session.

Using EncryptedMaps for encrypted key-value storage

EncryptedMaps is a higher-level abstraction that combines KeyManager (access-controlled vetKey derivation) with encrypted storage. It manages key derivation, access control, and client-side encryption transparently. Each named map is secured with a single vetKey; all key-value pairs in the map share the same access permissions.

import { EncryptedMaps } from "@dfinity/vetkeys/encrypted_maps";
// encryptedMapsClientInstance connects to your backend canister
const encryptedMaps = new EncryptedMaps(encryptedMapsClientInstance);
const mapOwner = Principal.fromText("aaaaa-aa");
const mapName = "passwords";
const mapKey = "email_account";
// Store an encrypted value (encryption is automatic)
const value = new TextEncoder().encode("my_secure_password");
await encryptedMaps.setValue(mapOwner, mapName, mapKey, value);
// Retrieve and decrypt a stored value
const stored = await encryptedMaps.getValue(mapOwner, mapName, mapKey);
// Grant another user read-write access to the map
const user = Principal.fromText("bbbbbb-bb");
await encryptedMaps.setUserRights(mapOwner, mapName, user, { ReadWrite: null });

The backend EncryptedMaps component stores only ciphertext; all plaintext stays on the frontend. See the password manager example (Motoko + Rust) for a full implementation, or the password manager with metadata variant that adds unencrypted metadata alongside encrypted values.

For the Rust backend, EncryptedMaps lives in ic_vetkeys::encrypted_maps; for TypeScript, import from @dfinity/vetkeys/encrypted_maps.

Identity-based encryption (IBE)

IBE lets anyone encrypt a message to a principal using only the canister’s public key. The recipient authenticates to the canister, obtains their corresponding vetKey, and decrypts. No prior key exchange is needed and the sender does not need the recipient to be online.

Encrypt (sender, no canister call needed):

import {
IbeCiphertext,
IbeIdentity,
IbeSeed,
DerivedPublicKey,
} from "@dfinity/vetkeys";
// Derive the canister's IBE public key (fetch once, cache)
const publicKeyBytes = await backendActor.get_public_key();
const ibePublicKey = DerivedPublicKey.deserialize(new Uint8Array(publicKeyBytes));
// Encrypt to the recipient's principal
const recipientIdentity = IbeIdentity.fromBytes(recipientPrincipalBytes);
const seed = IbeSeed.random();
const plaintext = new TextEncoder().encode("secret message");
const ciphertext = IbeCiphertext.encrypt(
ibePublicKey,
recipientIdentity,
plaintext,
seed,
);
const serialized = ciphertext.serialize(); // store in the canister or transmit

Decrypt (recipient, after obtaining vetKey):

import { IbeCiphertext } from "@dfinity/vetkeys";
// Obtain the vetKey for the recipient's principal (steps 2-3 above)
const vetKey = /* ... decryptAndVerify as shown in Step 3 ... */;
const deserialized = IbeCiphertext.deserialize(serialized);
const decrypted = deserialized.decrypt(vetKey);
// decrypted is Uint8Array of the plaintext

See the basic IBE example (Motoko + Rust) for a complete backend and frontend implementation. For IBE with a time-based release condition (timelock encryption), see the secret-bid auction example.

Testing locally

Start the local network and deploy:

Terminal window
icp network start -d
icp deploy backend

The local network automatically provisions both test_key_1 and key_1. Verify that your canister returns a public key:

Terminal window
icp canister call backend get_public_key '()'
# Returns: (blob "...") -- 48+ bytes of BLS public key data

For vetkd_derive_key testing, use the chain-key testing canister (vrqyr-saaaa-aaaan-qzn4q-cai) on mainnet as a lower-cost alternative during development. It provides a fake vetKD implementation with no threshold. Use key name insecure_test_key_1. Never use it with real data or in production.

Common mistakes

  • Reusing transport keys across sessions. Each session must generate a fresh transport key pair.
  • Using the raw vetKey as an AES key. Always call toDerivedKeyMaterial() first; do not pass the raw bytes to importKey.
  • Putting secret data in the input field. The input is sent to the management canister in plaintext. Use it as an identifier (principal, document ID), not for the secret data itself.
  • Mismatched context between getPublicKey and getEncryptedVetKey. Both endpoints must derive context from the same inputs (domain separator + caller principal). If they differ, decryptAndVerify will fail silently.
  • Not attaching enough cycles to vetkd_derive_key. test_key_1 costs approximately 10 billion cycles; key_1 costs approximately 26 billion cycles.

Next steps