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"Add ic-vetkeys to mops.toml:
[package]name = "my-vetkd-app"version = "0.1.0"
[dependencies]core = "2.0.0"Frontend (TypeScript):
npm install @dfinity/vetkeys@0.4.0Step 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 };};use ic_cdk::update;
const DOMAIN_SEPARATOR: &[u8] = b"my_app_v1";
/// Encodes domain separator + caller principal so each caller's keys are isolated.fn caller_context(caller: candid::Principal) -> Vec<u8> { [DOMAIN_SEPARATOR.len() as u8] .into_iter() .chain(DOMAIN_SEPARATOR.iter().copied()) .chain(caller.as_slice().iter().copied()) .collect()}
fn key_id() -> ic_cdk::management_canister::VetKDKeyId { ic_cdk::management_canister::VetKDKeyId { curve: ic_cdk::management_canister::VetKDCurve::Bls12_381_G2, name: "test_key_1".to_string(), // Use "key_1" for production }}
#[update]async fn get_public_key() -> Vec<u8> { let caller = ic_cdk::caller(); let request = ic_cdk::management_canister::VetKDPublicKeyArgs { canister_id: None, context: caller_context(caller), key_id: key_id(), }; let reply = ic_cdk::management_canister::vetkd_public_key(&request) .await .expect("vetkd_public_key call failed"); reply.public_key}
#[update]async fn get_encrypted_vetkey(input: Vec<u8>, transport_public_key: Vec<u8>) -> Vec<u8> { let caller = ic_cdk::caller(); // capture before await // test_key_1 costs ~10B cycles; key_1 costs ~26B cycles let request = ic_cdk::management_canister::VetKDDeriveKeyArgs { input, context: caller_context(caller), transport_public_key, key_id: key_id(), }; let reply = ic_cdk::management_canister::vetkd_derive_key(&request) .await .expect("vetkd_derive_key call failed"); reply.encrypted_key}
ic_cdk::export_candid!();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. BothgetPublicKeyandgetEncryptedVetKeymust use the same context so thatdecryptAndVerifysucceeds 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 readcallerbefore anyawaitin 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 withconst 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 keyconst aesKeyMaterial = vetKey.toDerivedKeyMaterial();const aesKey = await crypto.subtle.importKey( "raw", aesKeyMaterial.data.slice(0, 32), { name: "AES-GCM" }, false, ["encrypt", "decrypt"],);
// Encryptconst 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
// Decryptconst 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 canisterconst 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 valueconst stored = await encryptedMaps.getValue(mapOwner, mapName, mapKey);
// Grant another user read-write access to the mapconst 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 principalconst 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 transmitDecrypt (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 plaintextSee 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:
icp network start -dicp deploy backendThe local network automatically provisions both test_key_1 and key_1. Verify that your canister returns a public key:
icp canister call backend get_public_key '()'# Returns: (blob "...") -- 48+ bytes of BLS public key dataFor 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 toimportKey. - Putting secret data in the
inputfield. Theinputis sent to the management canister in plaintext. Use it as an identifier (principal, document ID), not for the secret data itself. - Mismatched
contextbetweengetPublicKeyandgetEncryptedVetKey. Both endpoints must derive context from the same inputs (domain separator + caller principal). If they differ,decryptAndVerifywill fail silently. - Not attaching enough cycles to
vetkd_derive_key.test_key_1costs approximately 10 billion cycles;key_1costs approximately 26 billion cycles.
Next steps
- VetKeys concept: how the vetKD protocol works and what use cases it enables
- Data integrity: certified variables and response verification
- Internet Identity: authenticate users before granting access to vetKeys
- vetkeys examples: password manager, encrypted notes, IBE messaging, BLS signing, and secret-bid auction
- ic-vetkeys library: Rust crate and TypeScript package source