Skip to main content

vetKeys example

Advanced
Encryption

This example provides a canister (src/chainkey_testing_canister) that demonstrates how the proposed vetKD system API can be used to encrypt, transfer, and decrypt a file sent to an Ethereum address.

The GitHub repo for this example is a public template, and there is a live demo available: https://h62xu-2iaaa-aaaal-qshoq-cai.icp0.io/

It uses ic-siwe and ic-siwe-js to facilitate signing in with Ethereum, sending a file to an Ethereum address, and using the recipient's address to to identify the IBE scheme used to generate a public key for the recipient.

Other examples available are:

Encrypt and store a file

Step 1: Retrieve recipient's public key

First, identify the Ethereum address of the recipient you'd like to send a file to. Then, define a vetkd_public_key method that will retrieve the recipient's public key using their Ethereum address to identify the IBE scheme that generates the public key. This method will call the chainkey_testing_canister:

src/backend/src/vetkd/controller/vetkd_public_key.rs
use crate::declarations::chainkey_testing_canister::{
chainkey_testing_canister, VetkdCurve, VetkdPublicKeyArgs, VetkdPublicKeyArgsKeyId,
};
use ic_cdk::update;

#[update]
async fn vetkd_public_key() -> Result<Vec<u8>, String> {
let args = VetkdPublicKeyArgs {
key_id: VetkdPublicKeyArgsKeyId {
name: "insecure_test_key_1".to_string(),
curve: VetkdCurve::Bls12381G2,
},
derivation_path: vec![],
canister_id: None,
};

let (result,) = chainkey_testing_canister
.vetkd_public_key(args)
.await
.unwrap();

Ok(result.public_key.to_vec())
}

This example uses a local test vetKeys canister. When the feature is available, this should be replaced with the actual vetKeys API.

Step 2: Encrypt a file in the frontend

Next, define a request in the application's frontend that uses the recipient's public key and the vetkd.IBECiphertext.encrypt method from the ic-vetkd-utils package. This request will take the file uploaded by the user and encrypt it.

src/frontend/src/transfer/hooks/useTransferCreate.tsx
import { useMutation } from "@tanstack/react-query";
import * as vetkd from "ic-vetkd-utils";
import { useActor } from "@/ic/Actors";
import { toBytes } from "viem";

export default function useTransferCreate() {
const { actor: backend } = useActor();

return useMutation({
mutationFn: async ({
recipientAddress,
file,
}: {
recipientAddress: string;
file: File;
}) => {
if (!backend) {
console.error("Backend actor not available");
return;
}
const response = await backend.vetkd_public_key();
if ("Err" in response) {
console.error("Error getting recipient public key", response.Err);
return;
}

const recipientPublicKey = response.Ok as Uint8Array;
const seed = window.crypto.getRandomValues(new Uint8Array(32));
const fileBuffer = await file.arrayBuffer();
const encodedMessage = new Uint8Array(fileBuffer);
const encryptedFile = vetkd.IBECiphertext.encrypt(
recipientPublicKey,
toBytes(recipientAddress),
encodedMessage,
seed
);
const request = {
to: recipientAddress,
content_type: file.type,
filename: file.name,
data: encryptedFile.serialize(),
};
return backend.transfer_create(request);
},
});
}

Step 3: Store the encrypted file in the backend

In the backend canister, create a transfer_create method that verifies the frontend request's from and to parameters, then stores the encrypted file:

src/backend/src/transfer/controller/transfer_create.rs
use crate::{
transfer::{
transfer_manager::{TransferManager, TransferManagerCreateArgs},
transfer_types::Transfer,
},
user::user_manager::UserManager,
utils::principal_to_blob,
};
use alloy::primitives::Address;
use candid::CandidType;
use ic_cdk::update;
use serde::Deserialize;

#[derive(CandidType, Deserialize, Debug, Clone)]
pub struct TransferCreateRequest {
pub to: String,
pub filename: String,
pub content_type: String,
pub data: Vec<u8>,
}

#[update]
pub async fn transfer_create(args: TransferCreateRequest) -> Result<Transfer, String> {
let principal_blob = principal_to_blob(ic_cdk::caller());
let from = UserManager::get(principal_blob).ok_or("User not found".to_string())?;
let to = Address::parse_checksummed(args.to, None).map_err(|e| e.to_string())?;
let transfer = TransferManager::create(TransferManagerCreateArgs {
from,
to,
filename: args.filename,
content_type: args.content_type,
data: args.data,
})?;
Ok(transfer)
}

Receive and decrypt a file

Step 1: Obtain a list of available encrypted file transfers

The file has now been encrypted with the intended recipient's Ethereum address and corresponding private key. In order for the recipient to receive the file, they must login to the application with their Ethereum address. The frontend will call the backend's transfer_list method and receive the application's existing file transfers.

src/backend/src/transfer/controller/transfer_list.rs
loading...

Step 2: Obtain the recipient's private key

Then the backend obtains the encrypted private key of the user's Ethereum address in a similar manner to how it obtained the public key to encrypt the file:

src/backend/src/vetkd/controller/vetkd_encrypted_key.rs
use crate::{
declarations::chainkey_testing_canister::{
chainkey_testing_canister, VetkdCurve, VetkdDeriveEncryptedKeyArgs,
VetkdDeriveEncryptedKeyArgsKeyId,
},
utils::get_caller_address,
};
use ic_cdk::update;
use serde_bytes::ByteBuf;

#[update]
async fn vetkd_encrypted_key(encryption_public_key: Vec<u8>) -> Result<Vec<u8>, String> {
let address = get_caller_address().await?;

let args = VetkdDeriveEncryptedKeyArgs {
key_id: VetkdDeriveEncryptedKeyArgsKeyId {
name: "insecure_test_key_1".to_string(),
curve: VetkdCurve::Bls12381G2,
},
derivation_path: vec![],
derivation_id: ByteBuf::from(*address.0),
encryption_public_key: ByteBuf::from(encryption_public_key),
};

let (result,) = chainkey_testing_canister
.vetkd_derive_encrypted_key(args)
.await
.unwrap();

Ok(result.encrypted_key.to_vec())
}

Step 3: Decrypt the private key and file

Finally, the frontend decrypts the private key, uses it to decrypt the file, and returns the file to the user:

src/frontend/src/transfer/hooks/useTransferGet.tsx
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as vetkd from "ic-vetkd-utils";
import { useQuery } from "@tanstack/react-query";
import useVetkdEncryptedKey from "@/vetkd/hooks/useVetkdEncryptedKey";
import useVetkdPublicKey from "@/vetkd/hooks/useVetkdPublicKey";
import { useAccount } from "wagmi";
import { useActor } from "@/ic/Actors";
import { toBytes } from "viem";

export default function useTransferGet(transferId: number) {
const { actor: backend } = useActor();
const { address } = useAccount();
const { data: vetkdEncryptedKeyReturn } = useVetkdEncryptedKey();
const { data: publicKey } = useVetkdPublicKey();
return useQuery({
queryKey: ["transfer_get", transferId, address],
queryFn: async () => {
const response = await backend?.transfer_get(transferId);
if (!response) {
console.error("Error fetching transfer, empty response");
return;
}
if ("Err" in response) {
console.error("Error fetching transfer", response.Err);
return;
}
const transfer = response.Ok;
const { transportSecretKey, encryptedKey } = vetkdEncryptedKeyReturn!;
try {
const key = transportSecretKey.decrypt(
encryptedKey,
publicKey!,
toBytes(address!)
);
const ibeCiphertext = vetkd.IBECiphertext.deserialize(
transfer.data as Uint8Array
);
const decryptedData = ibeCiphertext.decrypt(key);
return { decryptedData, ...transfer };
} catch (e) {
console.error("Error decrypting transfer", e);
}
},
enabled: !!backend && !!vetkdEncryptedKeyReturn && !!publicKey && !!address,
});
}