From 9d20bfeed87ee45f752383b056f5eb94f1ac968a Mon Sep 17 00:00:00 2001 From: Andrea Cerulli Date: Fri, 10 Apr 2026 11:08:46 +0200 Subject: [PATCH] Replace vetkd skill with vetkeys covering BLS, IBE, and timelock. The old vetkd skill was too low-level and complex. The new vetkeys skill covers three practical primitives: BLS threshold signatures, identity-based encryption (IBE), and timelock encryption with compile-tested code examples. --- skills/vetkd/SKILL.md | 624 ---------------------------------------- skills/vetkeys/SKILL.md | 508 ++++++++++++++++++++++++++++++++ 2 files changed, 508 insertions(+), 624 deletions(-) delete mode 100644 skills/vetkd/SKILL.md create mode 100644 skills/vetkeys/SKILL.md diff --git a/skills/vetkd/SKILL.md b/skills/vetkd/SKILL.md deleted file mode 100644 index b650059..0000000 --- a/skills/vetkd/SKILL.md +++ /dev/null @@ -1,624 +0,0 @@ ---- -name: vetkd -description: "Implement on-chain encryption using vetKeys (verifiable encrypted threshold key derivation). Covers key derivation, IBE encryption/decryption, transport keys, and access control. Use when adding encryption, decryption, on-chain privacy, vetKeys, or identity-based encryption to a canister. Do NOT use for authentication — use internet-identity instead." -license: Apache-2.0 -compatibility: "icp-cli >= 0.2.2" -metadata: - title: vetKeys - category: Security ---- - -# vetKeys (Verifiable Encrypted Threshold Keys) - -> **Note:** vetKeys is a newer feature of the IC. The `ic-vetkeys` Rust crate and `@dfinity/vetkeys` -> npm package are published, but the APIs may still change over time. -> Pin your dependency versions and check the [DFINITY forum](https://forum.dfinity.org) for any migration guides after upgrades. - -## What This Is - -vetKeys (verifiably encrypted threshold keys) bring on-chain privacy to the IC via the **vetKD** protocol: secure, on-demand key derivation so that a public blockchain can hold and work with secret data. Keys are **verifiable** (users can check correctness and lack of tampering), **encrypted** (derived keys are encrypted under a user-supplied transport key—no node or canister ever sees the raw key), and **threshold** (a quorum of subnet nodes cooperates to derive keys; no single party has the master key). A canister requests a derived key from the subnet’s threshold infrastructure, receives it encrypted under the client’s transport public key, and only the client decrypts it locally. This unlocks decentralized key management (DKMS), encrypted on-chain storage, private messaging, identity-based encryption (IBE), timelock encryption, threshold BLS, and verifiable randomness—use cases. - -## Prerequisites - -- Rust: `ic-vetkeys = "0.6"` ([crates.io](https://crates.io/crates/ic-vetkeys)) -- Motoko: Use the raw management canister approach shown below -- Frontend: `@dfinity/vetkeys` v0.4.0 - -## Canister IDs - -| Canister | ID | Purpose | -|----------|-----|---------| -| Management Canister | `aaaaa-aa` | Exposes `vetkd_public_key` and `vetkd_derive_key` system APIs | -| Chain-key testing canister | `vrqyr-saaaa-aaaan-qzn4q-cai` | **Testing only:** fake vetKD implementation to test key derivation without paying production API fees. Insecure, do not use in production. | - -The management canister is not a real canister, it is a system-level API endpoint. Calls to `aaaaa-aa` are routed by the system to the vetKD-enabled subnet that holds the master key specified in `key_id`; that subnet's nodes run the threshold key derivation. Your canister can call from any subnet. - -**Testing canister:** The [chain-key testing canister](https://github.com/dfinity/chainkey-testing-canister) is deployed on mainnet and provides a fake vetKD implementation (hard-coded keys, no threshold) so you can exercise key derivation without production cycle costs. Use key name `insecure_test_key_1`. **Insecure, for testing only:** never use it in production or with sensitive data. You can also deploy your own instance from the repo. - -### Master Key Names and API Fees - -Any canister on the IC can use any available master key regardless of which subnet the canister or the key resides on; the management canister routes calls to the subnet that holds the master key. - -| Key name | Environment | Purpose | Cycles (approx.) | Notes | -|----------------|------------------|-------------------|--------------------|-------| -| `test_key_1` | Local + Mainnet | Development & testing | 10_000_000_000 (mainnet) | Works both locally and on mainnet. Use for development and testing. | -| `key_1` | Mainnet | Production | 26_153_846_153 | Subnet pzp6e (backed up on uzr34) | - -Fees depend on the **subnet where the master key resides** (and its size), not on the calling canister's subnet. If the canister may be blackholed or used by other canisters, send **more cycles** than the current cost so that future subnet size increases do not cause calls to fail; unused cycles are refunded. See [vetKD API — API fees](https://docs.internetcomputer.org/building-apps/network-features/vetkeys/api#api-fees) for current USD estimates. - -## Key Concepts - -- **vetKey**: Key material derived deterministically from `(canister_id, context, input)`. Same inputs always produce the same key. Neither the canister nor any subnet node ever sees the raw key, as it is encrypted under the client's transport key until decrypted locally. -- **Transport key**: An ephemeral key pair generated by the client. The public key is sent to the canister so the IC can encrypt the derived key for delivery. Only the client holding the corresponding private key can decrypt the result. -- **Context**: A domain separator blob. Isolates derived subkeys per use case (e.g. per feature or key purpose) and prevents key collisions within the same canister. Think of it as a namespace. -- **Input**: Application-defined data that identifies which key to derive (e.g. user principal, file ID, chat room ID). It is sent in plaintext to the management canister. Use it only as an identifier, never for secret data. -- **IBE (Identity-Based Encryption)**: A scheme where you encrypt to an identity (e.g. a principal) using a derived public key. vetKeys enables IBE on the IC: anyone can encrypt to a principal using the canister's derived public key; only that principal can obtain the matching vetKey and decrypt. - -## Mistakes That Break Your Build - -1. **Not pinning dependency versions.** The `ic-vetkeys` crate and `@dfinity/vetkeys` npm package are published, but the APIs may still change in new releases. Pin your versions and re-test after upgrades. If something stops working after an upgrade, consult the relevant change notes to understand what happened. - -2. **Reusing transport keys across sessions.** Each session must generate a fresh transport key pair. The Rust and TypeScript libraries include support for generating keys safely; use them if at all possible. - -3. **Using raw `vetkd_derive_key` output as an encryption key.** The output is an encrypted blob. You must decrypt it with the transport secret to get the vetKey (raw key material). What you do next depends on your use case: for example, you might derive a symmetric key (e.g. for AES) via `toDerivedKeyMaterial()` or the equivalent. Do not use the decrypted bytes directly as an AES key. Other uses (IBE decryption, signing, etc.) consume the vetKey in their own way; the libraries document the right pattern for each. - -4. **Confusing vetKD with traditional public-key crypto.** There are no static key pairs per user. Keys are derived on-demand from the subnet's threshold master key (via the vetKD protocol). The same (canister, context, input) always yields the same derived key. - -5. **Putting secret data in the `input` field.** The input is sent to the management canister in plaintext. It is a key identifier, not encrypted payload. Use it for IDs (principal, document ID), never for the actual secret data. - -6. **Forgetting that `vetkd_derive_key` is an async inter-canister call.** It costs cycles and requires `await`. Capture `caller` before the await as defensive practice. - -7. **Using `context` inconsistently.** If the backend uses `b"my_app_v1"` as context but the frontend verification uses `b"my_app"`, the derived keys will not match and decryption will silently fail. - -8. **Not attaching enough cycles to `vetkd_derive_key`.** `vetkd_derive_key` consumes cycles; `vetkd_public_key` does not. For derive_key, `key_1` costs ~26B cycles and `test_key_1` costs ~10B cycles. - -9. **Rolling your own IBE without proper authorization checks.** If you implement IBE manually (bypassing `KeyManager` / `EncryptedMaps`), your canister must enforce that `vetkd_derive_key` only returns the derived key to the authorized caller — e.g. the principal whose identity was used as the `input`. Without this check, any caller can request any derived key and decrypt messages meant for someone else. The provided `ic-vetkeys` / `@dfinity/vetkeys` libraries handle this correctly; prefer them over a custom implementation. - -## System API (Candid) - -The vetKD API lets canisters request vetKeys derived by the threshold protocol. Derivation is **deterministic**: the same inputs always produce the same key, so keys can be retrieved reliably. Different inputs yield different keys—canisters can derive an unlimited number of unique keys. Summary below; full spec: [vetKD API](https://docs.internetcomputer.org/building-apps/network-features/vetkeys/api) and the [IC interface specification](https://internetcomputer.org/docs/current/references/ic-interface-spec#ic-vetkd_derive_key). - -### vetkd_public_key - -Returns a public key used to **verify** keys derived with `vetkd_derive_key`. With an empty context you get the canister-level master public key; with a non-empty context you get the derived subkey for that context. In IBE, this public key lets anyone encrypt to an identity (e.g. a principal); only the holder of that identity can later obtain the matching vetKey and decrypt—no prior key exchange or recipient presence required. - -```candid -vetkd_public_key : (record { - canister_id : opt canister_id; - context : blob; - key_id : record { curve : vetkd_curve; name : text }; -}) -> (record { public_key : blob }) -``` - -- `canister_id`: Optional. If omitted (`null`), the public key for the **calling canister** is returned; if provided, the key for that canister is returned. -- `context`: Domain separator which has the same meaning as in `vetkd_derive_key`. Ensures keys are derived in a specific context and avoids collisions across apps or use cases. -- `key_id.curve`: `bls12_381_g2` (only supported curve). -- `key_id.name`: Master key name: `test_key_1` (local + mainnet testing) or `key_1` (production). - -You can also derive this public key **offline** from the known mainnet master public key; see "Offline Public Key Derivation" below. - -### vetkd_derive_key - -Derives key material for the given (context, input) and returns it **encrypted** under the recipient's transport public key. Only the holder of the transport secret can decrypt. The decrypted material is then used according to your use case (e.g. via `toDerivedKeyMaterial()` for symmetric keys, or for IBE decryption). - -```candid -vetkd_derive_key : (record { - input : blob; - context : blob; - transport_public_key : blob; - key_id : record { curve : vetkd_curve; name : text }; -}) -> (record { encrypted_key : blob }) -``` - -- `input`: Arbitrary data used as the key identifier—different inputs yield different derived keys. Does not need to be random; sent in plaintext to the management canister. -- `context`: Domain separator; must match the context used when obtaining the public key (e.g. for verification or IBE). -- `transport_public_key`: The recipient's public key; the derived key is encrypted under this for secure delivery. -- Returns: `encrypted_key`. Decrypt with the transport secret to get the raw vetKey, then use it as required (e.g. derive a symmetric key; do not use raw bytes directly as an AES key). - -Master key names and cycle costs are in **Master Key Names and API Fees** under Canister IDs. - -## Implementation - -### Rust - -**Cargo.toml:** - -```toml -[dependencies] -candid = "0.10" -ic-cdk = "0.19" -serde = { version = "1", features = ["derive"] } -serde_bytes = "0.11" - -# High-level library (recommended) — source: https://github.com/dfinity/vetkeys -ic-vetkeys = "0.6" -ic-stable-structures = "0.7" -``` - -**Using ic-vetkeys library (recommended):** - -```rust -use candid::Principal; -use ic_cdk::update; -use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; -use ic_stable_structures::DefaultMemoryImpl; -use ic_vetkeys::key_manager::KeyManager; -use ic_vetkeys::types::{AccessRights, VetKDCurve, VetKDKeyId}; - -// KeyManager is generic over an AccessControl type — AccessRights is the default. -// It uses stable memory for persistent storage of access control state. -thread_local! { - static MEMORY_MANAGER: std::cell::RefCell> = - std::cell::RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); - - static KEY_MANAGER: std::cell::RefCell>> = - std::cell::RefCell::new(None); -} - -#[ic_cdk::init] -fn init() { - let key_id = VetKDKeyId { - curve: VetKDCurve::Bls12381G2, - name: "key_1".to_string(), // "test_key_1" for local + mainnet testing - }; - MEMORY_MANAGER.with(|mm| { - let mm = mm.borrow(); - KEY_MANAGER.with(|km| { - *km.borrow_mut() = Some(KeyManager::init( - "my_app_v1", // domain separator - key_id, - mm.get(MemoryId::new(0)), // config memory - mm.get(MemoryId::new(1)), // access control memory - mm.get(MemoryId::new(2)), // shared keys memory - )); - }); - }); -} - -#[update] -async fn get_encrypted_vetkey(subkey_id: Vec, transport_public_key: Vec) -> Vec { - let caller = ic_cdk::caller(); // Capture BEFORE await - let future = KEY_MANAGER.with(|km| { - let km = km.borrow(); - let km = km.as_ref().expect("not initialized"); - km.get_encrypted_vetkey(caller, subkey_id, transport_public_key) - .expect("access denied") - }); - future.await -} - -#[update] -async fn get_vetkey_verification_key() -> Vec { - let future = KEY_MANAGER.with(|km| { - let km = km.borrow(); - let km = km.as_ref().expect("not initialized"); - km.get_vetkey_verification_key() - }); - future.await -} -``` - -**Calling management canister directly (lower level):** - -```rust -use candid::{CandidType, Deserialize, Principal}; -use ic_cdk::update; - -#[derive(CandidType, Deserialize)] -struct VetKdKeyId { - curve: VetKdCurve, - name: String, -} - -#[derive(CandidType, Deserialize)] -enum VetKdCurve { - #[serde(rename = "bls12_381_g2")] - Bls12381G2, -} - -#[derive(CandidType)] -struct VetKdPublicKeyRequest { - canister_id: Option, - context: Vec, - key_id: VetKdKeyId, -} - -#[derive(CandidType, Deserialize)] -struct VetKdPublicKeyResponse { - public_key: Vec, -} - -#[derive(CandidType)] -struct VetKdDeriveKeyRequest { - input: Vec, - context: Vec, - transport_public_key: Vec, - key_id: VetKdKeyId, -} - -#[derive(CandidType, Deserialize)] -struct VetKdDeriveKeyResponse { - encrypted_key: Vec, -} - -const CONTEXT: &[u8] = b"my_app_v1"; - -fn key_id() -> VetKdKeyId { - VetKdKeyId { - curve: VetKdCurve::Bls12381G2, - // Key names: "test_key_1" for local + mainnet testing, "key_1" for production - name: "key_1".to_string(), - } -} - -#[update] -async fn vetkd_public_key() -> Vec { - let request = VetKdPublicKeyRequest { - canister_id: None, // defaults to this canister - context: CONTEXT.to_vec(), - key_id: key_id(), - }; - - // vetkd_public_key does not require cycles (unlike vetkd_derive_key). - let (response,): (VetKdPublicKeyResponse,) = ic_cdk::api::call::call( - Principal::management_canister(), // aaaaa-aa - "vetkd_public_key", - (request,), - ) - .await - .expect("vetkd_public_key call failed"); - - response.public_key -} - -#[update] -async fn vetkd_derive_key(transport_public_key: Vec) -> Vec { - let caller = ic_cdk::caller(); // MUST capture before await - - let request = VetKdDeriveKeyRequest { - input: caller.as_slice().to_vec(), // derive key specific to this caller - context: CONTEXT.to_vec(), - transport_public_key, - key_id: key_id(), - }; - - // key_1 costs ~26B cycles, test_key_1 costs ~10B cycles. - let (response,): (VetKdDeriveKeyResponse,) = ic_cdk::api::call::call_with_payment128( - Principal::management_canister(), - "vetkd_derive_key", - (request,), - 26_000_000_000, // cycles for key_1 (use 10_000_000_000 for test_key_1) - ) - .await - .expect("vetkd_derive_key call failed"); - - response.encrypted_key -} -``` - -### Motoko - -**mops.toml:** - -```toml -[package] -name = "my-vetkd-app" -version = "0.1.0" - -[dependencies] -core = "2.0.0" -``` - -**Using the management canister directly:** - -```motoko -import Blob "mo:core/Blob"; -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 context : Blob = Text.encodeUtf8("my_app_v1"); - - // Key names: "test_key_1" for local + mainnet testing, "key_1" for production - func keyId() : VetKdKeyId { - { curve = #bls12_381_g2; name = "key_1" } - }; - - public shared func getPublicKey() : async Blob { - // vetkd_public_key does not require cycles (unlike vetkd_derive_key). - let response = await managementCanister.vetkd_public_key({ - canister_id = null; - context; - key_id = keyId(); - }); - response.public_key - }; - - public shared ({ caller }) func deriveKey(transportPublicKey : Blob) : async Blob { - // caller is captured here, before the await. vetkd_derive_key requires cycles. - let response = await (with cycles = 26_000_000_000) managementCanister.vetkd_derive_key({ - input = Principal.toBlob(caller); - context; - transport_public_key = transportPublicKey; - key_id = keyId(); - }); - response.encrypted_key - }; -}; -``` - -### Frontend (TypeScript) - -The frontend generates a transport key pair, sends the public half to the canister, receives the encrypted derived key, decrypts it with the transport secret to get the vetKey (raw key material), then derives a symmetric key from that material (e.g. via `toDerivedKeyMaterial()`) for AES or other use. - -```typescript -import { TransportSecretKey, DerivedPublicKey, EncryptedVetKey } from "@dfinity/vetkeys"; - -// 1. Generate a transport secret key (BLS12-381) -const seed = crypto.getRandomValues(new Uint8Array(32)); -const transportSecretKey = TransportSecretKey.fromSeed(seed); -const transportPublicKey = transportSecretKey.publicKey(); - -// 2. Request encrypted vetkey and verification key from your canister -const [encryptedKeyBytes, verificationKeyBytes] = await Promise.all([ - backendActor.get_encrypted_vetkey(subkeyId, transportPublicKey), - backendActor.get_vetkey_verification_key(), -]); - -// 3. Deserialize and decrypt -const verificationKey = DerivedPublicKey.deserialize(new Uint8Array(verificationKeyBytes)); -const encryptedVetKey = EncryptedVetKey.deserialize(new Uint8Array(encryptedKeyBytes)); -const vetKey = encryptedVetKey.decryptAndVerify( - transportSecretKey, - verificationKey, - new Uint8Array(subkeyId), -); - -// 4. Derive a symmetric key for AES-GCM -const aesKeyMaterial = vetKey.toDerivedKeyMaterial(); -const aesKey = await crypto.subtle.importKey( - "raw", - aesKeyMaterial.data.slice(0, 32), // 256-bit AES key - { name: "AES-GCM" }, - false, - ["encrypt", "decrypt"], -); - -// 5. Encrypt -const iv = crypto.getRandomValues(new Uint8Array(12)); -const ciphertext = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - aesKey, - new TextEncoder().encode("secret message"), -); - -// 6. Decrypt -const plaintext = await crypto.subtle.decrypt( - { name: "AES-GCM", iv }, - aesKey, - ciphertext, -); -``` - -The `@dfinity/vetkeys` package also provides higher-level abstractions via sub-paths: - -- **`@dfinity/vetkeys/key_manager`** -- `KeyManager` and `DefaultKeyManagerClient` for managing access-controlled keys -- **`@dfinity/vetkeys/encrypted_maps`** -- `EncryptedMaps` and `DefaultEncryptedMapsClient` for encrypted key-value storage - -These mirror the Rust `KeyManager` and `EncryptedMaps` types and handle the transport key flow automatically. - -### Offline Public Key Derivation - -You can derive public keys offline (without any canister calls) from the known mainnet master public key for a given key name (e.g. `key_1`). This is useful for IBE: you derive the canister's public key for your context, then encrypt to an identity (e.g. a principal) without the recipient or the canister being online. - -**Rust:** - -```rust -use ic_vetkeys::{MasterPublicKey, DerivedPublicKey}; - -// Start from the known mainnet master public key for key_1 -let master_key = MasterPublicKey::for_mainnet_key("key_1") - .expect("unknown key name"); - -// Derive the canister-level key -let canister_key = master_key.derive_canister_key(canister_id.as_slice()); - -// Derive a sub-key for a specific context/identity -let derived_key: DerivedPublicKey = canister_key.derive_sub_key(b"my_app_v1"); - -// Use derived_key for IBE encryption — no canister call needed -``` - -**TypeScript:** - -```typescript -import { MasterPublicKey, DerivedPublicKey } from "@dfinity/vetkeys"; - -// Start from the known mainnet master public key -const masterKey = MasterPublicKey.productionKey(); - -// Derive the canister-level key -const canisterKey = masterKey.deriveCanisterKey(canisterId); - -// Derive a sub-key for a specific context/identity -const derivedKey: DerivedPublicKey = canisterKey.deriveSubKey( - new TextEncoder().encode("my_app_v1"), -); - -// Use derivedKey for IBE encryption — no canister call needed -``` - -### Identity-Based Encryption (IBE) - -IBE lets you encrypt to an identity (e.g. a principal) using only the canister's derived public key—the recipient does not need to be online or have registered a key beforehand. The recipient later authenticates to the canister, obtains their vetKey (derived for that identity) via `vetkd_derive_key`, and decrypts locally. - -**TypeScript:** - -```typescript -import { - TransportSecretKey, DerivedPublicKey, EncryptedVetKey, - IbeCiphertext, IbeIdentity, IbeSeed, -} from "@dfinity/vetkeys"; - -// --- Encrypt (sender side, no canister call needed) --- - -// Derive the recipient's public key offline (see "Offline Public Key Derivation" above) -const recipientIdentity = IbeIdentity.fromBytes(recipientPrincipalBytes); -const seed = IbeSeed.random(); -const plaintext = new TextEncoder().encode("secret message"); - -const ciphertext = IbeCiphertext.encrypt(derivedPublicKey, recipientIdentity, plaintext, seed); -const serialized = ciphertext.serialize(); // store or transmit this - -// --- Decrypt (recipient side, requires canister call to get vetKey) --- - -// 1. Get the vetKey (same flow as the Frontend section above) -const transportSecretKey = TransportSecretKey.fromSeed(crypto.getRandomValues(new Uint8Array(32))); -const [encryptedKeyBytes, verificationKeyBytes] = await Promise.all([ - backendActor.get_encrypted_vetkey(subkeyId, transportSecretKey.publicKey()), - backendActor.get_vetkey_verification_key(), -]); -const verificationKey = DerivedPublicKey.deserialize(new Uint8Array(verificationKeyBytes)); -const encryptedVetKey = EncryptedVetKey.deserialize(new Uint8Array(encryptedKeyBytes)); -const vetKey = encryptedVetKey.decryptAndVerify( - transportSecretKey, verificationKey, new Uint8Array(subkeyId), -); - -// 2. Decrypt the IBE ciphertext -const deserialized = IbeCiphertext.deserialize(serialized); -const decrypted = deserialized.decrypt(vetKey); -// decrypted is Uint8Array containing "secret message" -``` - -**Rust (off-chain client or test):** - -```rust -use ic_vetkeys::{ - DerivedPublicKey, IbeCiphertext, IbeIdentity, IbeSeed, VetKey, -}; - -// --- Encrypt --- -let identity = IbeIdentity::from_bytes(recipient_principal.as_slice()); -let seed = IbeSeed::new(&mut rand::rng()); -let plaintext = b"secret message"; - -let ciphertext = IbeCiphertext::encrypt( - &derived_public_key, - &identity, - plaintext, - &seed, -); -let serialized = ciphertext.serialize(); - -// --- Decrypt (after obtaining the VetKey) --- -let deserialized = IbeCiphertext::deserialize(&serialized) - .expect("invalid ciphertext"); -let decrypted = deserialized.decrypt(&vet_key) - .expect("decryption failed"); -// decrypted == b"secret message" -``` - -### Higher-Level Abstractions: KeyManager & EncryptedMaps - -Both the Rust crate and TypeScript package provide two higher-level modules that handle the transport key flow, access control, and encrypted storage for you: - -- **`KeyManager`** (Rust) / **`KeyManager`** (TS) — Manages access-controlled vetKeys with stable storage. The canister enforces who may request which keys; the library handles derivation requests, user rights (`Read`, `ReadWrite`, `ReadWriteManage`), and key sharing between principals. - -- **`EncryptedMaps`** (Rust) / **`EncryptedMaps`** (TS) — Builds on KeyManager to provide an encrypted key-value store. Each map is access-controlled and encrypted under a derived vetKey. Encryption and decryption of values are handled on the client (frontend) using vetKeys; the canister only stores ciphertext. - -In Rust, these live in `ic_vetkeys::key_manager` and `ic_vetkeys::encrypted_maps`. In TypeScript, import from `@dfinity/vetkeys/key_manager` and `@dfinity/vetkeys/encrypted_maps`. See the [vetkeys repository](https://github.com/dfinity/vetkeys) for full examples. - -## Deploy & Test - -### Local Development - -```bash -# Start the local network (provisions test_key_1 and key_1 automatically) -icp network start -d - -# Deploy your canister -icp deploy backend - -# Test public key retrieval -icp canister call backend getPublicKey '()' -# Returns: (blob "...") -- the vetKD public key - -# For derive_key, you need a transport public key (generated by frontend) -# Test with a dummy 48-byte blob: -icp canister call backend deriveKey '(blob "\00\01\02\03\04\05\06\07\08\09\0a\0b\0c\0d\0e\0f\10\11\12\13\14\15\16\17\18\19\1a\1b\1c\1d\1e\1f\20\21\22\23\24\25\26\27\28\29\2a\2b\2c\2d\2e\2f")' -``` - -### Mainnet - -```bash -# Deploy to mainnet -icp deploy backend -e ic - -# Use test_key_1 for initial testing, key_1 for production -# Make sure your canister code references the correct key name -``` - -## Verify It Works - -```bash -# 1. Verify public key is returned (non-empty blob) -icp canister call backend getPublicKey '()' -# Expected: (blob "\ab\cd\ef...") -- 48+ bytes of BLS public key data - -# 2. Verify derive_key returns encrypted key (non-empty blob) -icp canister call backend deriveKey '(blob "\00\01...")' -# Expected: (blob "\12\34\56...") -- encrypted key material - -# 3. Verify determinism: same (caller, context, input) and same transport key produce same encrypted_key -# Call deriveKey twice with the same identity and transport key -# Expected: identical encrypted_key blobs both times - -# 4. Verify isolation: different callers get different keys -icp identity new test-user-1 --storage-mode=plaintext -icp identity new test-user-2 --storage-mode=plaintext -icp identity default test-user-1 -icp canister call backend deriveKey '(blob "\00\01...")' -# Note the output -icp identity default test-user-2 -icp canister call backend deriveKey '(blob "\00\01...")' -# Expected: DIFFERENT encrypted_key (different caller = different derived key) - -# 5. Frontend integration test -# Open the frontend, trigger encryption/decryption -# Verify: encrypted data can be decrypted by the same user -# Verify: encrypted data CANNOT be decrypted by a different user -``` diff --git a/skills/vetkeys/SKILL.md b/skills/vetkeys/SKILL.md new file mode 100644 index 0000000..a007ada --- /dev/null +++ b/skills/vetkeys/SKILL.md @@ -0,0 +1,508 @@ +--- +name: vetkeys +description: "Advanced vetKeys primitives on the Internet Computer: BLS threshold signatures, Identity-Based Encryption (IBE), and timelock encryption. Use when building verifiable signatures, encrypted messaging where senders encrypt to a recipient's identity (no key exchange needed), sealed-bid auctions, time-locked secrets, or verifiable randomness (VRF). Do NOT use for encrypted key-value storage with access control (use encrypted-maps instead)." +license: Apache-2.0 +compatibility: "icp-cli >= 0.2.2" +metadata: + title: VetKeys + category: Security +--- + +# VetKeys: BLS Signatures, IBE, and Timelock Encryption + +## What This Is + +VetKeys (Verifiably Encrypted Threshold Keys) is a threshold key derivation protocol on the Internet Computer. This skill covers three advanced primitives built on vetKeys: + +- **BLS Signatures**: Threshold BLS12-381 signing. The canister signs messages using the `sign_with_bls` system API. The signing key is split among the subnet nodes and never fully reconstructed. +- **Identity-Based Encryption (IBE)**: Encrypt to a recipient's identity (e.g., their principal) without needing their public key first. The recipient later derives their decryption key from the IC. +- **Timelock Encryption**: A special case of IBE where the "identity" is a future event (e.g., timestamp or auction lot ID). Data stays encrypted until the canister derives the decryption key after the event occurs. + +For encrypted key-value storage with access control and sharing, use the `encrypted-maps` skill instead — it provides a higher-level abstraction that handles all crypto internally. + +Reference implementations: [vetkeys/examples](https://github.com/dfinity/vetkeys/tree/main/examples) + +## Prerequisites + +Verify: `icp --version` must be >= 0.2.2. + +### Rust + +```toml +# Cargo.toml +[dependencies] +candid = "0.10" +ic-cdk = "0.19.0" +ic-cdk-timers = "1.0.0" # only needed for timelock (timer-based decryption) +ic-stable-structures = "0.7.0" +ic-vetkeys = "0.6.0" +ic-dummy-getrandom-for-wasm = "0.1.0" +serde = "1" +serde_bytes = "0.11" +serde_cbor = "0.11" +``` + +### Frontend + +```json +{ + "dependencies": { + "@dfinity/agent": "^3.4.0", + "@dfinity/principal": "^3.4.0", + "@dfinity/vetkeys": "^0.4.0" + } +} +``` + +## Key Concepts + +- **VetKD Key ID**: Every vetKeys operation references a key ID with a curve (`Bls12_381_G2`) and a name (`"test_key_1"` for testing, `"key_1"` for production). The key name is set at canister init. +- **Context (domain separator)**: A byte string that scopes key derivation per application. Different contexts produce different keys from the same master key. Always use a unique domain separator per app. +- **Input**: The derivation input determines *which* key is derived. For BLS signing this is the message. For IBE this is the recipient's identity. For timelock this is the event identifier. +- **Transport key**: For IBE, the encrypted vetKey is transported to the client using an ephemeral transport key pair. The `@dfinity/vetkeys` frontend library handles this automatically via `TransportSecretKey`. +- **Unencrypted vs encrypted vetKey**: BLS signatures and timelock encryption use *unencrypted* vetKeys (the canister derives them directly via `sign_with_bls`). IBE uses *encrypted* vetKeys (the client decrypts them locally to extract the private decryption key). +- **Cycle costs**: `vetkd_public_key` is free. `vetkd_derive_key` (and `sign_with_bls`) costs cycles: ~10B for `test_key_1` on mainnet, ~26B for `key_1` on mainnet. Locally, PocketIC (used by icp-cli) charges ~26B for any key name. The `ic-vetkeys` helpers attach cycles automatically. If your canister may be blackholed, send extra cycles — subnet size increases can raise costs; unused cycles are refunded. +- **Chain-key testing canister** (`vrqyr-saaaa-aaaan-qzn4q-cai`): A fake vetKD implementation deployed on mainnet for cheap integration testing. Uses key name `insecure_test_key_1`. No threshold security — **never use in production or with sensitive data**. +- **Offline public key derivation**: For IBE, you can derive a canister's public key entirely offline from the known mainnet master public key — no canister call needed. This means the sender can encrypt to a recipient's identity without the canister or recipient being online. + +## Common Pitfalls + +1. **Using raw `vetkd_derive_key` instead of `ic_vetkeys` helpers.** The `ic-vetkeys` crate provides `management_canister::sign_with_bls()` and `management_canister::bls_public_key()` which handle cycle attachment and the unencrypted transport key trick. Do not call `vetkd_derive_key` directly for BLS or timelock. + +2. **Forgetting the domain separator in the context.** Without a domain separator, keys derived by a canister for a certain context could collide with keys derived by the same canister in a different context. Always prefix the context with a unique app identifier. For BLS signing, the context typically includes both the domain separator and the signer's principal. + +3. **Using an encrypted vetKey for BLS signatures.** BLS signatures require the canister to see the key (it *is* the signature). Use `sign_with_bls` which derives an unencrypted vetKey. If you use `vetkd_derive_key` with a real transport key, you get an encrypted key which the canister has to decrypt before using it. + +4. **Mixing up `input` and `context` for IBE vs BLS.** For BLS signing: `input` = message bytes, `context` = domain separator + signer identity. For IBE: `input` = recipient identity, `context` = domain separator. Getting these swapped means wrong keys and failed verification/decryption. + +5. **Not handling `getrandom` for Wasm.** The `ic-vetkeys` crate depends on crates that use `getrandom`. Add `ic-dummy-getrandom-for-wasm = "0.1.0"` to your dependencies — it registers a custom `getrandom` implementation for the `wasm32-unknown-unknown` target. + +## Implementation + +### BLS Signatures (Rust Backend) + +A canister that signs messages with threshold BLS and stores signatures for later verification. + +```rust +// backend/src/lib.rs +use candid::Principal; +use ic_cdk::management_canister::{VetKDCurve, VetKDKeyId, VetKDPublicKeyArgs}; +use ic_cdk::{init, query, update}; +use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; +use ic_stable_structures::{Cell as StableCell, DefaultMemoryImpl, StableBTreeMap}; +use serde_bytes::ByteBuf; +use std::cell::RefCell; + +mod types; +use types::Signature; + +type Memory = VirtualMemory; + +thread_local! { + static MEMORY_MANAGER: RefCell> = + RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); + static SIGNATURES: RefCell> = + RefCell::new(StableBTreeMap::init( + MEMORY_MANAGER.with_borrow(|m| m.get(MemoryId::new(0))), + )); + static KEY_NAME: RefCell> = + RefCell::new(StableCell::init( + MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(1))), + String::new(), + )); +} + +#[init] +fn init(key_name: String) { + KEY_NAME.with_borrow_mut(|kn| kn.set(key_name)); +} + +#[update] +async fn sign_message(message: String) -> ByteBuf { + let signer = ic_cdk::api::msg_caller(); + let signature_bytes = ic_vetkeys::management_canister::sign_with_bls( + message.as_bytes().to_vec(), + context(&signer), + key_id(), + ) + .await + .expect("sign_with_bls failed"); + + // Store the signature + SIGNATURES.with_borrow_mut(|sigs| { + let timestamp = ic_cdk::api::time(); + let sig = Signature { + message, + signature: signature_bytes.clone(), + timestamp, + }; + let mut ts = timestamp; + while sigs.get(&(signer, ts)).is_some() { + ts += 1; // handle same-round collisions + } + sigs.insert((signer, ts), sig); + }); + + ByteBuf::from(signature_bytes) +} + +#[update] +async fn get_my_verification_key() -> ByteBuf { + let request = VetKDPublicKeyArgs { + canister_id: None, + context: context(&ic_cdk::api::msg_caller()), + key_id: key_id(), + }; + let result = ic_cdk::management_canister::vetkd_public_key(&request) + .await + .expect("vetkd_public_key failed"); + ByteBuf::from(result.public_key) +} + +#[query] +fn get_my_signatures() -> Vec { + let me = ic_cdk::api::msg_caller(); + SIGNATURES.with_borrow(|sigs| { + sigs.range((me, 0)..) + .take_while(|entry| entry.key().0 == me) + .map(|entry| entry.value()) + .collect() + }) +} + +fn context(signer: &Principal) -> Vec { + const DOMAIN_SEPARATOR: &[u8] = b"my_bls_signing_dapp"; + let mut ctx = vec![DOMAIN_SEPARATOR.len() as u8]; + ctx.extend_from_slice(DOMAIN_SEPARATOR); + ctx.extend_from_slice(signer.as_ref()); + ctx +} + +fn key_id() -> VetKDKeyId { + VetKDKeyId { + curve: VetKDCurve::Bls12_381_G2, + name: KEY_NAME.with_borrow(|kn| kn.get().clone()), + } +} + +ic_cdk::export_candid!(); +``` + +```rust +// backend/src/types.rs +use candid::CandidType; +use ic_stable_structures::{storable::Bound, Storable}; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct Signature { + pub message: String, + #[serde(with = "serde_bytes")] + pub signature: Vec, + pub timestamp: u64, +} + +impl Storable for Signature { + fn to_bytes(&self) -> Cow<[u8]> { + Cow::Owned(serde_cbor::to_vec(self).expect("failed to serialize")) + } + fn into_bytes(self) -> Vec { + serde_cbor::to_vec(&self).expect("failed to serialize") + } + fn from_bytes(bytes: Cow<[u8]>) -> Self { + serde_cbor::from_slice(&bytes).expect("failed to deserialize") + } + const BOUND: Bound = Bound::Unbounded; +} +``` + +### BLS Verification (Frontend) + +```typescript +import { DerivedPublicKey, verifyBlsSignature } from "@dfinity/vetkeys"; + +// Get the public key from the canister +const pubKeyBytes = new Uint8Array(await backend.get_my_verification_key()); +const publicKey = DerivedPublicKey.deserialize(pubKeyBytes); + +// Sign a message via the canister +const message = new TextEncoder().encode("hello world"); +const signatureBytes = new Uint8Array(await backend.sign_message("hello world")); + +// Verify the signature (can be done anywhere — no canister call needed) +const isValid = verifyBlsSignature(publicKey, message, signatureBytes); +``` + +### IBE: Encrypted Messaging (Rust Backend) + +The canister structure (init, `thread_local!`, `MemoryManager`, `key_id()`) is the same as the BLS example. Replace the BLS-specific endpoints with these IBE endpoints. The key differences: IBE uses `vetkd_public_key` and `vetkd_derive_key` directly (not `sign_with_bls`), and the context is just the domain separator (no signer principal). + +```rust +const DOMAIN_SEPARATOR: &str = "my_ibe_messaging_dapp"; + +#[update] +async fn get_ibe_public_key() -> ByteBuf { + let request = VetKDPublicKeyArgs { + canister_id: None, + context: DOMAIN_SEPARATOR.as_bytes().to_vec(), + key_id: key_id(), + }; + let result = ic_cdk::management_canister::vetkd_public_key(&request) + .await + .expect("vetkd_public_key failed"); + ByteBuf::from(result.public_key) +} + +#[update] +async fn get_my_encrypted_ibe_key(transport_key: ByteBuf) -> ByteBuf { + let caller = ic_cdk::api::msg_caller(); + let request = VetKDDeriveKeyArgs { + input: caller.as_ref().to_vec(), // recipient identity = caller's principal + context: DOMAIN_SEPARATOR.as_bytes().to_vec(), + key_id: key_id(), + transport_public_key: transport_key.into_vec(), + }; + let result = ic_cdk::management_canister::vetkd_derive_key(&request) + .await + .expect("vetkd_derive_key failed"); + ByteBuf::from(result.encrypted_key) +} + +#[update] +fn send_message(request: SendMessageRequest) -> Result<(), String> { + let sender = ic_cdk::api::msg_caller(); + let timestamp = ic_cdk::api::time(); + INBOXES.with_borrow_mut(|inboxes| { + let mut inbox = inboxes.get(&request.receiver).unwrap_or_default(); + if inbox.messages.len() >= 1000 { + return Err(format!("Inbox for {} is full", request.receiver)); + } + inbox.messages.push(Message { sender, encrypted_message: request.encrypted_message, timestamp }); + inboxes.insert(request.receiver, inbox); + Ok(()) + }) +} +``` + +Storage uses `StableBTreeMap` where `Inbox` contains a `Vec`. Both implement `Storable` via `serde_cbor` (same pattern as `Signature` above). + +### IBE: Encrypt and Decrypt (Frontend) + +```typescript +import { Principal } from "@dfinity/principal"; +import { + TransportSecretKey, + DerivedPublicKey, + EncryptedVetKey, + IbeCiphertext, + IbeIdentity, + IbeSeed, +} from "@dfinity/vetkeys"; + +// --- Encrypt a message to a recipient's principal --- +// 1. Get the IBE public key from the canister (cache this) +const pubKeyBytes = new Uint8Array(await backend.get_ibe_public_key()); +const ibePublicKey = DerivedPublicKey.deserialize(pubKeyBytes); + +// 2. Encrypt to the recipient's identity +const recipient = Principal.fromText("xxxxx-xxxxx-xxxxx-xxxxx-cai"); +const plaintext = new TextEncoder().encode("secret message"); + +const ciphertext = IbeCiphertext.encrypt( + ibePublicKey, + IbeIdentity.fromPrincipal(recipient), + plaintext, + IbeSeed.random(), +); + +// 3. Send the serialized ciphertext to the canister +await backend.send_message({ + receiver: recipient, + encrypted_message: ciphertext.serialize(), +}); + +// --- Decrypt messages in your inbox --- +// 1. Generate a transport key pair +const transportSecretKey = TransportSecretKey.random(); + +// 2. Request your encrypted IBE private key from the canister +const encKeyBytes = new Uint8Array( + await backend.get_my_encrypted_ibe_key(transportSecretKey.publicKeyBytes()), +); + +// 3. Decrypt and verify the IBE private key locally +const myPrincipal = identity.getPrincipal(); +const ibePrivateKey = EncryptedVetKey.deserialize(encKeyBytes).decryptAndVerify( + transportSecretKey, + ibePublicKey, + new Uint8Array(myPrincipal.toUint8Array()), +); + +// 4. Decrypt each message +const inbox = await backend.get_my_messages(); +for (const msg of inbox.messages) { + const ct = IbeCiphertext.deserialize(new Uint8Array(msg.encrypted_message)); + const decrypted = ct.decrypt(ibePrivateKey); + console.log(new TextDecoder().decode(decrypted)); +} +``` + +### Timelock Encryption + +Timelock encryption uses IBE where the "identity" is an event identifier (e.g., auction lot ID). The canister derives the decryption key only after the event occurs — until then, no one can decrypt. Key difference from IBE messaging: the canister itself decrypts (unencrypted vetKey), not the client. + +Pattern: client encrypts with `IbeCiphertext.encrypt()` using `IbeIdentity.fromBytes(lotId)` + IBE public key, sends ciphertext to canister. When the event occurs, the canister calls `vetkd_derive_key` with the event ID as input and decrypts all ciphertexts: + +```rust +// Canister-side decryption after the event occurs (e.g., auction closes) +async fn decrypt_ciphertexts( + identity: Vec, // the event ID (e.g., lot_id.to_le_bytes()) + encrypted_values: Vec<&[u8]>, // serialized IbeCiphertexts +) -> Vec, String>> { + // Use a dummy seed — the canister sees the key anyway (timelock pattern) + let transport_secret_key = ic_vetkeys::TransportSecretKey::from_seed(vec![0; 32]) + .expect("failed to create transport secret key"); + + let request = VetKDDeriveKeyArgs { + context: DOMAIN_SEPARATOR.as_bytes().to_vec(), + input: identity.clone(), + key_id: key_id(), + transport_public_key: transport_secret_key.public_key().to_vec(), + }; + + let result = ic_cdk::management_canister::vetkd_derive_key(&request) + .await + .expect("vetkd_derive_key failed"); + + let ibe_public_key = DerivedPublicKey::deserialize( + &get_ibe_public_key().await.into_vec(), + ).unwrap(); + let encrypted_vetkey = EncryptedVetKey::deserialize(&result.encrypted_key).unwrap(); + + let ibe_key = encrypted_vetkey + .decrypt_and_verify(&transport_secret_key, &ibe_public_key, identity.as_ref()) + .expect("failed to decrypt ibe key"); + + encrypted_values.iter().map(|ev| { + ic_vetkeys::IbeCiphertext::deserialize(ev) + .map_err(|e| format!("deserialize failed: {e}")) + .and_then(|c| c.decrypt(&ibe_key).map_err(|_| "decrypt failed".to_string())) + }).collect() +} +``` + +Register a timer in both `#[init]` and `#[post_upgrade]` to trigger decryption: + +```rust +use ic_cdk_timers::set_timer_interval; +// Call from both init() and post_upgrade() — timers don't survive upgrades +set_timer_interval(std::time::Duration::from_secs(5), || close_expired_lots()); +``` + +### Offline Public Key Derivation (IBE) + +For IBE, you can derive the canister's public key entirely offline from the known mainnet master public key — no canister call needed. + +**Rust:** + +```rust +use ic_vetkeys::{MasterPublicKey, DerivedPublicKey}; + +// Start from the known mainnet master public key for key_1 +let master_key = MasterPublicKey::for_mainnet_key("key_1") + .expect("unknown key name"); + +// Derive the canister-level key, then the sub-key for your context +let derived_key: DerivedPublicKey = master_key + .derive_canister_key(canister_id.as_slice()) + .derive_sub_key(b"my_ibe_messaging_dapp"); + +// Use derived_key for IBE encryption — no canister call needed +let ciphertext = IbeCiphertext::encrypt( + &derived_key, + &IbeIdentity::from_bytes(recipient_principal.as_slice()), + plaintext, + &IbeSeed::new(&mut rand::rng()), +); +``` + +**TypeScript:** + +```typescript +import { MasterPublicKey } from "@dfinity/vetkeys"; + +// Start from the known mainnet master public key +const masterKey = MasterPublicKey.productionKey(); + +// Derive the canister-level key, then the sub-key for your context +const derivedKey = masterKey + .deriveCanisterKey(canisterId) + .deriveSubKey(new TextEncoder().encode("my_ibe_messaging_dapp")); + +// Use derivedKey for IBE encryption — no canister call needed +const ciphertext = IbeCiphertext.encrypt( + derivedKey, + IbeIdentity.fromPrincipal(recipient), + plaintext, + IbeSeed.random(), +); +``` + +For local development (icp-cli uses PocketIC), the libraries ship hardcoded test keys: +- **Rust:** `MasterPublicKey::for_pocketic_key(&key_id)` — supports `key_1`, `test_key_1`, `dfx_test_key` +- **TypeScript:** `MasterPublicKey.pocketicKey(PocketIcMasterPublicKeyId.KEY_1)` — supports `KEY_1`, `TEST_KEY_1`, `DFX_TEST_KEY` + +### icp.yaml + +```yaml +canisters: + - name: backend + recipe: + type: "@dfinity/rust@v3.2.0" + configuration: + package: my-vetkeys-backend + init_args: '("test_key_1")' + + - name: frontend + recipe: + type: "@dfinity/asset-canister@v2.1.0" + configuration: + dir: frontend/dist + build: + - npm --prefix frontend install + - npm --prefix frontend run build + +networks: + - name: local + mode: managed + ii: true +``` + +Change `init_args` to `'("key_1")'` for production. The `@dfinity/rust` recipe runs `cargo build --package ` from the project root, so you need a workspace `Cargo.toml` at the root: + +```toml +# Cargo.toml (project root) +[workspace] +members = ["backend"] +resolver = "2" +``` + +## Deploy & Verify + +```bash +icp network start -d +icp deploy backend + +# BLS: sign a message — expected: non-empty blob (48 bytes) +icp canister call backend sign_message '("hello world")' + +# BLS: get verification key — expected: non-empty blob (96+ bytes) +icp canister call backend get_my_verification_key '()' + +# IBE: get public key — expected: non-empty blob (96+ bytes) +icp canister call backend get_ibe_public_key '()' + +# Frontend: encrypt a message to a principal, login as that principal, +# decrypt — plaintext matches original +``` \ No newline at end of file