5.1 Developing an encrypted notes dapp with vetKeys
Many types of applications rely on secure ways to store user data and keep certain aspects of the dapp private. On a blockchain, transactions are traditionally public, and dapps might rely on the storage within a web browser to store user-side secrets, which can lead to potential security risks.
The vetKeys feature provides developers with more enhanced privacy, encryption, threshold decryption, and security capabilities for their projects and dapps. The goal of vetKeys is to allow developers to retrieve encrypted information from external locations and share it with different users in a private and secure manner.
In this tutorial, you'll learn about how vetKeys enables different workflows and use cases for decentralized applications on ICP, and then you'll create a dapp that uses vetKeys to provide a secure, encrypted note-taking application.
The vetKeys feature is still in development, and the proposed system API can be reviewed in the proposed PR.
What are vetKeys?
vetKeys stands for Verifiably Encrypted Threshold Keys and enables several cryptographic functionalities on ICP. The feature focuses on facilitating encryption, data privacy, and security. From a high level, the main objective of vetKeys is to provide a threshold key derivation interface, to allow canisters to obtain encrypted threshold signatures, and to allow for symmetric encryption for public key or identity-based encryption.
To understand vetKeys, first let's cover a few fundamental cryptography concepts and some of their disadvantages that vetKeys strives to solve.
Crypto primitives
In cryptography, most tools are built from crypto primitives. Crypto primitives are core building blocks that can be used with their existing functionality, or they can be used to build other, more complex cryptographic protocols or tools. Some examples of crypto primitives are block ciphers such as AES, hash functions such as SHA3, or signature schemes such as ECDSA.
The vetKeys feature introduces a new primitive, vetKD, which extends the functionality of a primitive known as identity-based encryption (IBE). vetKD, or Verifiably Encrypted Threshold Key Derivation, allows encrypted data's decryption keys to be derived on demand.
Public key encryption (PKE)
Public key encryption is a commonly used form of encryption that allows for data to be communicated confidentially over a public channel by encrypting the data messages. For example, if User 1 wants to send an encrypted message to User 2, a PKE scheme might follow these steps:
User 2 can generate a public and private key pair using a key generation algorithm.
User 2 publishes their public key online, such as within a public key infrastructure.
User 1 can retrieve User 2's public key, then use it to encrypt a plaintext message to User 2 using an encryption algorithm. This will result in a ciphertext, which is the encrypted plaintext of the message after it is processed with an encryption algorithm. User 1 then can send the ciphertext to User 2.
User 2 can decrypt the ciphertext using their private key and a decryption algorithm. This will result in the decrypted, plaintext message.
Since the standard practice of PKE involves storing a public key on a trusted public key infrastructure and securely managing the secret key, PKE can get complicated and difficult quickly, resulting in developers being discouraged from using cryptography in their applications. vetKeys strives to make this aspect of cryptography easier for developers.
Identity-based encryption (IBE)
Identity-based encryption addresses some of the pitfalls of PKE, as it allows an arbitrary identifier string (or identity) to be used as a public key that is used to derive the private key. To compare IBE to PKE, consider the following scenario:
In an IBE scheme, there must be a trusted key deriver (KD) that is used to run the IBE key generation algorithm and generate the master key pair that contains the private and public key based on the identity provided.
Then, User 1 can run the IBE encryption algorithm to encrypt a plaintext message to User 2 under User 2's identity. This results in a ciphertext, which is then sent to User 2.
User 2 authenticates to the KD with their identity and requests the private key that corresponds to their identity.
The KD returns User 2's private key to them.
User 2 uses their private key to decrypt the ciphertext sent from User 1. This will result in the decrypted, plaintext message.
An important note about IBE is that it requires a central authority (the KD) to derive the decryption key. This is problematic for working with decentralized technologies that do not want to place trust in a central authority. This is another pitfall that vetKeys strives to address.
vetKD
Within vetKeys, the core piece is the key derivation primitive, or vetKD. The key derivation capabilities provided by vetKD are designed to provide key derivation without relying on a central authority by distributing the trust amongst multiple parties, then requiring that a minimum threshold of all parties collaborate to derive the private key. To do this, first a distributed key generation (DKG) protocol is used to create master keys in a distributed way, resulting in multiple parties (e.g., nodes in a network) holding shares of the master keys. Later, when a user wants to derive a decryption key (or vetKey) based on their identity, a threshold of nodes derives the user's vetKey from the user's identity and the shares of the master key they hold.
To further understand vetKD, consider the following scenario on ICP:
Nodes within the network participate in the DKG process to obtain shares of a master secret key and a master public key. Each node may hold a number of key shares, but no node may hold the majority amount of key shares.
User 1 encrypts a message under User 2's identity and the master public key. The resulting ciphertext is sent to User 2.
When User 2 wants to decrypt the message, they must authenticate their identity to ICP to retrieve their vetKey to decrypt the ciphertext sent by User 1. They start by using a transport key generation (TKG) algorithm to generate a transport public and private key pair. User 2 then sends the transport public key along with their request to authenticate their identity and derive a vetKey. The transport public key gives the network a way to send User 2 encrypted responses. This is the 'E' in vetKD.
If User 2's identity authentication passes, the nodes use the DKG algorithm to derive User 2's vetKey using the node's master key shares, then encrypt them using User 2's transport public key. Anyone can use an encryption key share (EKS) verification algorithm to verify that the encrypted key shares contain a legitimate decryption share. This is the 'V' in vetKD. The network's nodes can also combine the encrypted shares to produce the full vetKey for the identity.
Finally, a recovery algorithm enables User 2 to decrypt the response with their transport private key, which reveals the vetKey corresponding to their identity. User 2 can now decrypt the message sent by User 1.
A crucial point to note is that this allows users to retrieve decryption keys (or vetKeys) from any device, at any time, overcoming one of the core pain points of public key encryption. vetKeys are made possible by leveraging the extremely useful properties of BLS signatures.
BLS signatures
BLS signatures are a type of digital signature introduced in 2001. The name BLS comes from the authors of the signature, Dan Boneh, Ben Lynn, and Hovav Shacham. Threshold BLS signatures are widely used across the Internet Computer because they are short, unique, easy to port into a distributed setting, and are fast to compute.
BLS signatures use three algorithms:
A key generation algorithm that may be distributed or not.
A signing algorithm.
A verification algorithm.
In a threshold setting, BLS signatures include a fourth, combination algorithm.
To demonstrate how BLS signatures are used on ICP, consider the following scenario that ICP might use to validate that a message sent to an end-user (User 1) has been sent from ICP:
Nodes on the network participate in the distributed key generation (DKG) process and obtain private key shares.
Each node computes a signature share for a message using its share of the signing key.
The nodes use the combination algorithm to combine the signature shares and produce a single signature that's sent to User 1.
User 1 uses the verification algorithm to check whether the signature that was sent uses the public key of the Internet Computer, validating that it was signed by the nodes on ICP.
In the scenario of vetKD, the master key of the IBE scheme uses a BLS signature key secret that is shared amongst the nodes. The derived identity is threshold signed, resulting in a signature that acts as a symmetric encryption key.
Each of these components of vetKeys are described further in the vetKeys primer.
vetKeys example dapp
In this example, you'll create a dapp that allows you to create notes and encrypt them using vetKeys. The notes will be encrypted with an AES key that is derived from a principal-specific vetKey that the backend canister will obtain using the vetKD system API. This prevents the need for any device management within the dapp.
This example uses an insecure implementation of the proposed vetKD system API. This should not be used in production or for encrypting any sensitive data; this example is for demonstration purposes only.
- Prerequisites
This example is currently not available in ICP Ninja and must be run locally with dfx
.
To get started, open a new terminal window, navigate into your working directory (developer_liftoff
), then use the following commands to clone the DFINITY examples repo and navigate into the encrypted-notes-dapp-vetkd
directory:
git clone https://github.com/dfinity/examples/
cd examples/motoko/encrypted-notes-dapp-vetkd
Setting up the project
Since you'll be using the Motoko variation of this project, you will need to set a $BUILD_END
local environment variable to equal motoko
:
export BUILD_ENV=motoko
Next, run the pre_deploy.sh
script, which will generate some files specific to the build environment; in this case, these will be files for the Motoko variation:
sh ./pre_deploy.sh
Install the project's required npm packages with the command:
npm install
Reviewing the project's files
First, start by opening the project's dfx.json
file to review the project's canisters and configuration:
{
"canisters": {
"vetkd_system_api": {
"candid": "vetkd_system_api.did",
"type": "custom",
"wasm": "vetkd_system_api.wasm"
},
"encrypted_notes_motoko": {
"dependencies": [
"vetkd_system_api"
],
"main": "src/encrypted_notes_motoko/main.mo",
"type": "motoko"
},
"encrypted_notes_rust": {
"dependencies": [
"vetkd_system_api"
],
"type": "rust",
"candid": "src/encrypted_notes_rust/src/encrypted_notes_rust.did",
"package": "encrypted_notes_rust"
},
"www": {
"dependencies": [
"encrypted_notes_{{ BUILD_ENV }}",
"vetkd_system_api"
],
"frontend": {
"entrypoint": "src/frontend/public/index.html"
},
"source": [
"src/frontend/public/"
],
"type": "assets"
},
"internet_identity": {
"candid": "https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity.did",
"type": "custom",
"wasm": "https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity_dev.wasm.gz"
}
},
"networks": {
"local": {
"bind": "0.0.0.0:8000",
"type": "ephemeral"
}
},
"version": 1
}
Here, you can see there are five canisters defined:
vetkd_system_api
: This canister is responsible for providing the vetKD system API that our dapp will use. It is built from local Candid and Wasm files.encrypted_notes_motoko
: This canister is responsible for the dapp's backend functionality, written in Motoko. This is the backend canister your dapp will use since this tutorial focuses on Motoko development.encrypted_notes_rust
: This canister is responsible for the dapp's backend functionality, written in Rust. This canister will not be deployed in this tutorial, since this tutorial showcases the Motoko development workflow.www
: This canister is responsible for providing the dapp's frontend user interface that you'll use to create encrypted notes from a web browser.internet_identity
: This canister is a local instance of the Internet Identity canister and is built from the Candid and Wasm files from the latest DFINITY Internet Identity release.
You can also see the defined local network, which uses the local address and port 0.0.0.0:8000
. This is the local network that your canisters will be deployed to.
Next, review the src/encrypted_notes_motoko/main.mo
file, which contains the core functionality for our dapp. This code has been annotated with notes to explain the program's functionality:
It is important to note that this backend canister does not perform any encryption, since it assumes that the notes are encrypted end-to-end by the front end (at the client side).
import Map "mo:base/HashMap";
import Text "mo:base/Text";
import Array "mo:base/Array";
import Buffer "mo:base/Buffer";
import List "mo:base/List";
import Iter "mo:base/Iter";
import Int "mo:base/Int";
import Nat "mo:base/Nat";
import Nat8 "mo:base/Nat8";
import Bool "mo:base/Bool";
import Principal "mo:base/Principal";
import Result "mo:base/Result";
import Option "mo:base/Option";
import Debug "mo:base/Debug";
import Order "mo:base/Order";
import Blob "mo:base/Blob";
import Hash "mo:base/Hash";
import Hex "./utils/Hex";
// Declare a shared actor class
// Bind the caller and the initializer
shared ({ caller = initializer }) actor class () {
// Currently, a single canister smart contract is limited to 4 GB of heap size.
// For the current limits see https://internetcomputer.org/docs/current/developer-docs/production/resource-limits.
// To ensure that our canister does not exceed the limit, we put various restrictions (e.g., max number of users) in place.
// This should keep us well below a memory usage of 2 GB because
// up to 2x memory may be needed for data serialization during canister upgrades.
// This is sufficient for this proof-of-concept, but in a production environment the actual
// memory usage must be calculated or monitored and the various restrictions adapted accordingly.
// Define dapp limits - important for security assurance
private let MAX_USERS = 500;
private let MAX_NOTES_PER_USER = 200;
private let MAX_NOTE_CHARS = 1000;
private let MAX_SHARES_PER_NOTE = 50;
private type PrincipalName = Text;
private type NoteId = Nat;
// Define public types
// Type of an encrypted note
// Attention: This canister does *not* perform any encryption.
// Here we assume that the notes are encrypted end-
// to-end by the front-end (at client side).
public type EncryptedNote = {
encrypted_text : Text;
id : Nat;
owner : PrincipalName;
// Principals with whom this note is shared. Does not include the owner.
// Needed to be able to efficiently show in the UI with whom this note is shared.
users : [PrincipalName];
};
// Define private fields
// Stable actor fields are automatically retained across canister upgrades.
// See https://internetcomputer.org/docs/current/motoko/main/upgrades/
// Design choice: Use globally unique note identifiers for all users.
//
// The keyword `stable` makes this (scalar) variable keep its value across canister upgrades.
//
// See https://internetcomputer.org/docs/current/developer-docs/setup/manage-canisters#upgrade-a-canister
private stable var nextNoteId : Nat = 1;
// Store notes by their ID, so that note-specific encryption keys can be derived.
private var notesById = Map.HashMap<NoteId, EncryptedNote>(0, Nat.equal, Hash.hash);
// Store which note IDs are owned by a particular principal
private var noteIdsByOwner = Map.HashMap<PrincipalName, List.List<NoteId>>(0, Text.equal, Text.hash);
// Store which notes are shared with a particular principal. Does not include the owner, as this is tracked by `noteIdsByOwner`.
private var noteIdsByUser = Map.HashMap<PrincipalName, List.List<NoteId>>(0, Text.equal, Text.hash);
// While accessing _heap_ data is more efficient, we use the following _stable memory_
// as a buffer to preserve data across canister upgrades.
// Stable memory is currently 96GB. For the current limits see
// https://internetcomputer.org/docs/current/developer-docs/production/resource-limits.
// See also: [preupgrade], [postupgrade]
private stable var stable_notesById : [(NoteId, EncryptedNote)] = [];
private stable var stable_noteIdsByOwner : [(PrincipalName, List.List<NoteId>)] = [];
private stable var stable_noteIdsByUser : [(PrincipalName, List.List<NoteId>)] = [];
// Utility function that helps writing assertion-driven code more concisely.
private func expect<T>(opt : ?T, violation_msg : Text) : T {
switch (opt) {
case (null) {
Debug.trap(violation_msg);
};
case (?x) {
x;
};
};
};
private func is_authorized(user : PrincipalName, note : EncryptedNote) : Bool {
user == note.owner or Option.isSome(Array.find(note.users, func(x : PrincipalName) : Bool { x == user }));
};
public shared ({ caller }) func whoami() : async Text {
return Principal.toText(caller);
};
// Shared functions, i.e., those specified with [shared], are
// accessible to remote callers.
// The extra parameter [caller] is the caller's principal
// See https://internetcomputer.org/docs/current/motoko/main/actors-async
// Add new empty note for this [caller].
//
// Returns:
// Future of ID of new empty note
// Traps:
// [caller] is the anonymous identity
// [caller] already has [MAX_NOTES_PER_USER] notes
// This is the first note for [caller] and [MAX_USERS] is exceeded
public shared ({ caller }) func create_note() : async NoteId {
assert not Principal.isAnonymous(caller);
let owner = Principal.toText(caller);
let newNote : EncryptedNote = {
id = nextNoteId;
encrypted_text = "";
owner = owner;
users = [];
};
switch (noteIdsByOwner.get(owner)) {
case (?owner_nids) {
assert List.size(owner_nids) < MAX_NOTES_PER_USER;
noteIdsByOwner.put(owner, List.push(newNote.id, owner_nids));
};
case null {
assert noteIdsByOwner.size() < MAX_USERS;
noteIdsByOwner.put(owner, List.make(newNote.id));
};
};
notesById.put(newNote.id, newNote);
nextNoteId += 1;
newNote.id;
};
// Returns (a future of) this [caller]'s notes.
//
// --- Queries vs. Updates ---
// Note that this method is declared as an *update* call (see `shared`) rather than *query*.
//
// While queries are significantly faster than updates, they are not certified by the IC.
// Thus, we avoid using queries throughout this dapp, ensuring that the result of our
// functions gets through consensus. Otherwise, this function could e.g. omit some notes
// if it got executed by a malicious node. (To make the dapp more efficient, one could
// use an approach in which both queries and updates are combined.)
// See https://internetcomputer.org/docs/current/concepts/canisters-code#query-and-update-methods
//
// Returns:
// Future of array of EncryptedNote
// Traps:
// [caller] is the anonymous identity
public shared ({ caller }) func get_notes() : async [EncryptedNote] {
assert not Principal.isAnonymous(caller);
let user = Principal.toText(caller);
let owned_notes = List.map(
Option.get(noteIdsByOwner.get(user), List.nil()),
func(nid : NoteId) : EncryptedNote {
expect(notesById.get(nid), "missing note with ID " # Nat.toText(nid));
},
);
let shared_notes = List.map(
Option.get(noteIdsByUser.get(user), List.nil()),
func(nid : NoteId) : EncryptedNote {
expect(notesById.get(nid), "missing note with ID " # Nat.toText(nid));
},
);
let buf = Buffer.Buffer<EncryptedNote>(List.size(owned_notes) + List.size(shared_notes));
buf.append(Buffer.fromArray(List.toArray(owned_notes)));
buf.append(Buffer.fromArray(List.toArray(shared_notes)));
Buffer.toArray(buf);
};
// Replaces the encrypted text of note with ID [id] with [encrypted_text].
//
// Returns:
// Future of unit
// Traps:
// [caller] is the anonymous identity
// note with ID [id] does not exist
// [caller] is not the note's owner and not a user with whom the note is shared
// [encrypted_text] exceeds [MAX_NOTE_CHARS]
public shared ({ caller }) func update_note(id : NoteId, encrypted_text : Text) : async () {
assert not Principal.isAnonymous(caller);
let caller_text = Principal.toText(caller);
let (?note_to_update) = notesById.get(id) else Debug.trap("note with id " # Nat.toText(id) # "not found");
if (not is_authorized(caller_text, note_to_update)) {
Debug.trap("unauthorized");
};
assert note_to_update.encrypted_text.size() <= MAX_NOTE_CHARS;
notesById.put(id, { note_to_update with encrypted_text });
};
// Shares the note with ID [note_id] with the [user].
// Has no effect if the note is already shared with that user.
//
// Returns:
// Future of unit
// Traps:
// [caller] is the anonymous identity
// note with ID [id] does not exist
// [caller] is not the note's owner
public shared ({ caller }) func add_user(note_id : NoteId, user : PrincipalName) : async () {
assert not Principal.isAnonymous(caller);
let caller_text = Principal.toText(caller);
let (?note) = notesById.get(note_id) else Debug.trap("note with id " # Nat.toText(note_id) # "not found");
if (caller_text != note.owner) {
Debug.trap("unauthorized");
};
assert note.users.size() < MAX_SHARES_PER_NOTE;
if (not Option.isSome(Array.find(note.users, func(u : PrincipalName) : Bool { u == user }))) {
let users_buf = Buffer.fromArray<PrincipalName>(note.users);
users_buf.add(user);
let updated_note = { note with users = Buffer.toArray(users_buf) };
notesById.put(note_id, updated_note);
};
switch (noteIdsByUser.get(user)) {
case (?user_nids) {
if (not List.some(user_nids, func(nid : NoteId) : Bool { nid == note_id })) {
noteIdsByUser.put(user, List.push(note_id, user_nids));
};
};
case null {
noteIdsByUser.put(user, List.make(note_id));
};
};
};
// Unshares the note with ID [note_id] with the [user].
// Has no effect if the note is already shared with that user.
//
// Returns:
// Future of unit
// Traps:
// [caller] is the anonymous identity
// note with ID [id] does not exist
// [caller] is not the note's owner
public shared ({ caller }) func remove_user(note_id : NoteId, user : PrincipalName) : async () {
assert not Principal.isAnonymous(caller);
let caller_text = Principal.toText(caller);
let (?note) = notesById.get(note_id) else Debug.trap("note with id " # Nat.toText(note_id) # "not found");
if (caller_text != note.owner) {
Debug.trap("unauthorized");
};
let users_buf = Buffer.fromArray<PrincipalName>(note.users);
users_buf.filterEntries(func(i : Nat, u : PrincipalName) : Bool { u != user });
let updated_note = { note with users = Buffer.toArray(users_buf) };
notesById.put(note_id, updated_note);
switch (noteIdsByUser.get(user)) {
case (?user_nids) {
let updated_nids = List.filter(user_nids, func(nid : NoteId) : Bool { nid != note_id });
if (not List.isNil(updated_nids)) {
noteIdsByUser.put(user, updated_nids);
} else {
let _ = noteIdsByUser.remove(user);
};
};
case null {};
};
};
// Delete the note with ID [id].
//
// Returns:
// Future of unit
// Traps:
// [caller] is the anonymous identity
// note with ID [id] does not exist
// [caller] is not the note's owner
public shared ({ caller }) func delete_note(note_id : NoteId) : async () {
assert not Principal.isAnonymous(caller);
let caller_text = Principal.toText(caller);
let (?note_to_delete) = notesById.get(note_id) else Debug.trap("note with id " # Nat.toText(note_id) # "not found");
let owner = note_to_delete.owner;
if (owner != caller_text) {
Debug.trap("unauthorized");
};
switch (noteIdsByOwner.get(owner)) {
case (?owner_nids) {
let updated_nids = List.filter(owner_nids, func(nid : NoteId) : Bool { nid != note_id });
if (not List.isNil(updated_nids)) {
noteIdsByOwner.put(owner, updated_nids);
} else {
let _ = noteIdsByOwner.remove(owner);
};
};
case null {};
};
for (user in note_to_delete.users.vals()) {
switch (noteIdsByUser.get(user)) {
case (?user_nids) {
let updated_nids = List.filter(user_nids, func(nid : NoteId) : Bool { nid != note_id });
if (not List.isNil(updated_nids)) {
noteIdsByUser.put(user, updated_nids);
} else {
let _ = noteIdsByUser.remove(user);
};
};
case null {};
};
};
let _ = notesById.remove(note_id);
};
// Only the vetKD methods in the IC management canister are required here.
type VETKD_SYSTEM_API = actor {
vetkd_public_key : ({
canister_id : ?Principal;
derivation_path : [Blob];
key_id : { curve : { #bls12_381 }; name : Text };
}) -> async ({ public_key : Blob });
vetkd_encrypted_key : ({
public_key_derivation_path : [Blob];
derivation_id : Blob;
key_id : { curve : { #bls12_381 }; name : Text };
encryption_public_key : Blob;
}) -> async ({ encrypted_key : Blob });
};
let vetkd_system_api : VETKD_SYSTEM_API = actor ("s55qq-oqaaa-aaaaa-aaakq-cai");
public shared ({ caller }) func symmetric_key_verification_key_for_note() : async Text {
let { public_key } = await vetkd_system_api.vetkd_public_key({
canister_id = null;
derivation_path = Array.make(Text.encodeUtf8("note_symmetric_key"));
key_id = { curve = #bls12_381; name = "test_key_1" };
});
Hex.encode(Blob.toArray(public_key));
};
public shared ({ caller }) func encrypted_symmetric_key_for_note(note_id : NoteId, encryption_public_key : Blob) : async Text {
let caller_text = Principal.toText(caller);
let (?note) = notesById.get(note_id) else Debug.trap("note with id " # Nat.toText(note_id) # "not found");
if (not is_authorized(caller_text, note)) {
Debug.trap("unauthorized");
};
let buf = Buffer.Buffer<Nat8>(32);
buf.append(Buffer.fromArray(natToBigEndianByteArray(16, note_id))); // fixed-size encoding
buf.append(Buffer.fromArray(Blob.toArray(Text.encodeUtf8(note.owner))));
let derivation_id = Blob.fromArray(Buffer.toArray(buf)); // prefix-free
let { encrypted_key } = await vetkd_system_api.vetkd_encrypted_key({
derivation_id;
public_key_derivation_path = Array.make(Text.encodeUtf8("note_symmetric_key"));
key_id = { curve = #bls12_381; name = "test_key_1" };
encryption_public_key;
});
Hex.encode(Blob.toArray(encrypted_key));
};
// Converts a nat to a fixed-size big-endian byte (Nat8) array
private func natToBigEndianByteArray(len : Nat, n : Nat) : [Nat8] {
let ith_byte = func(i : Nat) : Nat8 {
assert (i < len);
let shift : Nat = 8 * (len - 1 - i);
Nat8.fromIntWrap(n / 2 ** shift);
};
Array.tabulate<Nat8>(len, ith_byte);
};
// Below, we implement the upgrade hooks for our canister.
// See https://internetcomputer.org/docs/current/motoko/main/upgrades/
// The work required before a canister upgrade begins.
system func preupgrade() {
Debug.print("Starting pre-upgrade hook...");
stable_notesById := Iter.toArray(notesById.entries());
stable_noteIdsByOwner := Iter.toArray(noteIdsByOwner.entries());
stable_noteIdsByUser := Iter.toArray(noteIdsByUser.entries());
Debug.print("pre-upgrade finished.");
};
// The work required after a canister upgrade ends.
// See [nextNoteId], [stable_notesByUser]
system func postupgrade() {
Debug.print("Starting post-upgrade hook...");
notesById := Map.fromIter<NoteId, EncryptedNote>(
stable_notesById.vals(),
stable_notesById.size(),
Nat.equal,
Hash.hash,
);
stable_notesById := [];
noteIdsByOwner := Map.fromIter<PrincipalName, List.List<NoteId>>(
stable_noteIdsByOwner.vals(),
stable_noteIdsByOwner.size(),
Text.equal,
Text.hash,
);
stable_noteIdsByOwner := [];
noteIdsByUser := Map.fromIter<PrincipalName, List.List<NoteId>>(
stable_noteIdsByUser.vals(),
stable_noteIdsByUser.size(),
Text.equal,
Text.hash,
);
stable_noteIdsByUser := [];
Debug.print("post-upgrade finished.");
};
};
This tutorial will not dive into the frontend's configuration, as it is focused on the vetKey implementation within the backend of the dapp. Learn more about frontend canisters.
Starting a local replica
Before you can deploy the project's canisters, you'll need to ensure that a local replica is running with the command:
dfx start --clean --background
If you see the error:
Error: An error happened during communication with the replica: error sending request for url (http://0.0.0.0:8000/api/v2/status): error trying to connect: tcp connect error: Connection refused (os error 61)
This error indicates that the port 8000 is occupied. This is because this project's dfx.json
file is configured to use 0.0.0.0:8000
as the local network for canister deployment.
To resolve this error, ensure that port 8000 is not occupied.
Deploying the Internet Identity canister
Next, you'll need to deploy a local instance of the Internet Identity canister. The encrypted notes dapp uses Internet Identity to log in to the dapp. To install and deploy the canister, run the command:
dfx deploy internet_identity --argument '(null)'
This canister is defined in the project's dfx.json
file using the following Candid and Wasm files, which are pulled from the given URLs when the dfx deploy
command is used:
"internet_identity": {
"candid": "https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity.did",
"type": "custom",
"wasm": "https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity_dev.wasm.gz"
}
Once the canister is deployed, the canister's local URL will be returned. You can also print this URL with the command:
npm run print-dfx-ii
Output
> encrypted-notes-dapp@0.2.0 print-dfx-ii
> echo local Internet Identity: http://$(dfx canister id internet_identity).localhost:8000
local Internet Identity: http://bkyz2-fmaaa-aaaaa-qaaaq-cai.localhost:8000
Navigate to this URL in a web browser and create at least one local Internet Identity that you will use with the encrypted notes dapp.
Need a reminder on how to create an Internet Identity? Review the 3.5 Identities and authentication level of the Developer Liftoff.
Deploying the vetKD system API canister
Next, you will need to install and deploy the vetKD system API canister. For this canister, you want to ensure that dfx
uses the hard-coded canister ID s55qq-oqaaa-aaaaa-aaakq-cai
that is included in the backend canister's source code. To ensure that dfx
uses this canister ID, create and deploy the vetKD system API canister with the command:
dfx canister create vetkd_system_api --specified-id s55qq-oqaaa-aaaaa-aaakq-cai
dfx deploy vetkd_system_api
Remember that this canister is created from a local pre-compiled Wasm file, as defined in the project's dfx.json
file:
"vetkd_system_api": {
"candid": "vetkd_system_api.did",
"type": "custom",
"wasm": "vetkd_system_api.wasm"
},
This canister provides the following system API that the dapp will use:
vetkd_public_key : (record {
canister_id : opt canister_id;
derivation_path : vec blob;
key_id : record { curve : vetkd_curve; name : text };
}) -> (record { public_key : blob; });
vetkd_encrypted_key : (record {
public_key_derivation_path : vec blob;
derivation_id : blob;
key_id : record { curve : vetkd_curve; name : text };
encryption_public_key : blob;
}) -> (record { encrypted_key : blob; });
You can learn more about the vetKD API.
This feature is in development and should not be used in production environments at this time.
Deploying the encrypted notes backend canister
Now that your Internet Identity and vetKD system API canisters are running, you can deploy the encrypted notes backend canister using the command:
dfx deploy "encrypted_notes_$BUILD_ENV"
Updating the generated canister interface bindings
After deploying the backend canister, you must update the generated canister interface bindings with the command:
dfx generate "encrypted_notes_$BUILD_ENV"
Deploying the frontend canister
Deploy the frontend canister with the command:
dfx deploy www
This command will return the following output, indicating that the project's four canisters have been deployed:
Deployed canisters.
URLs:
Frontend canister via browser
www: http://0.0.0.0:8000/?canisterId=br5f7-7uaaa-aaaaa-qaaca-cai
Backend canister via Candid interface:
encryptedNotes_motoko: http://0.0.0.0:8000/?canisterId=bd3sg-teaaa-aaaaa-qaaba-cai&id=be2us-64aaa-aaaaa-qaabq-cai
internet_identity: http://0.0.0.0:8000/?canisterId=bd3sg-teaaa-aaaaa-qaaba-cai&id=bkyz2-fmaaa-aaaaa-qaaaq-cai
vetkd_system_api: http://0.0.0.0:8000/?canisterId=bd3sg-teaaa-aaaaa-qaaba-cai&id=s55qq-oqaaa-aaaaa-aaakq-cai
Starting the local development server
Now that your project's canisters are running, to interact with the dapp you need to start a local development server using the command:
npm run dev
Then, open the URL that is returned from this command. By default, this URL should be http://localhost:3000/.
Using the dapp
When you open the URL http://localhost:3000
, you will see the landing page for the 'Encrypted Notes' dapp.
Select the 'Please log in to start writing notes' button to authenticate with your Internet Identity.
Then, select your local Internet Identity to authenticate with.
Once logged in, you'll see the Encrypted Notes dashboard. You will see the option to create a new note and the option to see existing notes under 'Your notes' on the left sidebar menu.
To test the dapp, enter some text in the 'New note' box, then select 'Add note.'
Then, you can see the note has been saved in the 'Your notes' section:
Resources

Did you get stuck somewhere in this tutorial, or do you feel like you need additional help understanding some of the concepts? The ICP community has several resources available for developers, like working groups and bootcamps, along with our Discord community, forum, and events such as hackathons. Here are a few to check out:
- Developer Discord
- Developer Liftoff forum discussion
- Developer tooling working group
- Motoko Bootcamp - The DAO Adventure
- Motoko Bootcamp - Discord community
- Motoko developer working group
- Upcoming events and conferences
- Upcoming hackathons
- Weekly developer office hours to ask questions, get clarification, and chat with other developers live via voice chat.
- Submit your feedback to the ICP Developer feedback board