Skip to content

Commit 8afd2a7

Browse files
authored
chore(vetkeys): split encrypted_notes_app_vetkd into self-contained motoko + rust examples (#1445)
1 parent 9206b22 commit 8afd2a7

72 files changed

Lines changed: 2008 additions & 415 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/vetkeys-encrypted-notes-app-vetkd.yml

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ on:
66
- master
77
pull_request:
88
paths:
9+
- motoko/vetkeys/encrypted_notes_app_vetkd/**
910
- rust/vetkeys/encrypted_notes_app_vetkd/**
1011
- .github/workflows/vetkeys-encrypted-notes-app-vetkd.yml
1112

@@ -14,23 +15,28 @@ concurrency:
1415
cancel-in-progress: true
1516

1617
jobs:
17-
rust:
18+
motoko:
1819
runs-on: ubuntu-24.04
19-
container: ghcr.io/dfinity/icp-dev-env-rust:1.0.1
20+
container: ghcr.io/dfinity/icp-dev-env-motoko:1.0.1
2021
env:
2122
ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2223
steps:
2324
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
24-
- name: Deploy Encrypted Notes App Vetkd Rust
25-
working-directory: rust/vetkeys/encrypted_notes_app_vetkd/rust
26-
run: icp network start -d && icp deploy
27-
motoko:
25+
- name: Deploy
26+
working-directory: motoko/vetkeys/encrypted_notes_app_vetkd
27+
run: |
28+
icp network start -d
29+
icp deploy
30+
31+
rust:
2832
runs-on: ubuntu-24.04
29-
container: ghcr.io/dfinity/icp-dev-env-motoko:1.0.1
33+
container: ghcr.io/dfinity/icp-dev-env-rust:1.0.1
3034
env:
3135
ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3236
steps:
3337
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
34-
- name: Deploy Encrypted Notes App Vetkd Motoko
35-
working-directory: rust/vetkeys/encrypted_notes_app_vetkd/motoko
36-
run: icp network start -d && icp deploy
38+
- name: Deploy
39+
working-directory: rust/vetkeys/encrypted_notes_app_vetkd
40+
run: |
41+
icp network start -d
42+
icp deploy
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Encrypted Notes: vetKD (Motoko)
2+
3+
[View this sample's code on GitHub](https://github.com/dfinity/examples/tree/master/motoko/vetkeys/encrypted_notes_app_vetkd)
4+
5+
Also available in: [Rust](../../../rust/vetkeys/encrypted_notes_app_vetkd)
6+
7+
Encrypted notes is an example app for authoring and storing confidential information on the Internet Computer (ICP) in the form of short pieces of text. Users can create and access their notes via any number of automatically synchronized devices authenticated via Internet Identity (II). Notes are stored confidentially using vetKeys. The end-to-end encryption is performed by the app's frontend.
8+
9+
In particular, the notes are encrypted with an AES key that is derived (directly in the browser) from a note-ID-specific vetKey obtained from the backend canister (in encrypted form, using an ephemeral transport key), which itself obtains it from the vetKD system API. This way, there is no need for any device management in the app, plus sharing of notes becomes possible.
10+
11+
The vetKey used to encrypt and decrypt a note is note-ID-specific (and not, for example, principal-specific) to enable the sharing of notes between users. The derived AES keys are stored as non-extractable CryptoKeys in an IndexedDB in the browser for efficiency so that their respective vetKey only has to be fetched from the server once.
12+
13+
## Build and deploy from the command line
14+
15+
### Prerequisites
16+
17+
- Install [Node.js](https://nodejs.org/en/download/)
18+
- Install [icp-cli](https://cli.internetcomputer.org): `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm`
19+
- Install [ic-mops](https://mops.one): `npm install -g ic-mops`
20+
21+
### Install
22+
23+
```bash
24+
git clone https://github.com/dfinity/examples
25+
cd examples/motoko/vetkeys/encrypted_notes_app_vetkd
26+
```
27+
28+
### Deploy
29+
30+
```bash
31+
icp network start -d
32+
icp deploy
33+
```
34+
35+
Open the frontend URL printed by `icp deploy`.
36+
37+
To run the frontend in development mode with hot reloading (after `icp deploy`):
38+
39+
```bash
40+
npm run dev
41+
```
42+
43+
When done, stop the local network to free up the port for other projects:
44+
45+
```bash
46+
icp network stop
47+
```
48+
49+
## Example components
50+
51+
### Backend (`backend/`)
52+
53+
A single Motoko canister that stores encrypted notes. It is deployed automatically with `icp deploy`.
54+
55+
### Frontend (`frontend/`)
56+
57+
A **Svelte** application providing a user-friendly interface for managing encrypted notes. Canister bindings are generated from `backend/backend.did` at build time by the `@icp-sdk/bindgen` Vite plugin.
58+
59+
## Limitations
60+
61+
This example app does not implement key rotation, which is strongly recommended in a production environment.
62+
63+
## Troubleshooting
64+
65+
If you run into issues, clearing all the application-specific IndexedDBs in the browser might help. For example in Chrome, go to Inspect → Application → Local Storage → Clear All, and then reload.
66+
67+
## API level
68+
69+
This example intentionally uses the **raw vetKD management canister API** (`encryptedSymmetricKeyForNote`, `symmetricKeyVerificationKeyForNote`) to demonstrate how vetKD works at the protocol level.
70+
71+
For most applications, the higher-level [`EncryptedMaps`](https://github.com/dfinity/vetkeys/tree/main/frontend/ic_vetkeys/src/encrypted_maps) abstraction from `@icp-sdk/vetkeys` is the recommended approach — it handles key derivation, caching, and access control internally without requiring a custom crypto layer. See the **VetKD Password Manager** ([`../password_manager`](../password_manager)) and **Password Manager with Metadata** ([`../password_manager_with_metadata`](../password_manager_with_metadata)) examples for how `EncryptedMaps` is used in practice.
72+
73+
## Additional resources
74+
75+
- **[What are VetKeys](https://docs.internetcomputer.org/concepts/vetkeys)** — more information about VetKeys and VetKD.
76+
- [Security checklist for this example](security-checklist.md)
77+
- [Security best practices](https://docs.internetcomputer.org/guides/security/overview/)

rust/vetkeys/encrypted_notes_app_vetkd/motoko/backend/main.mo renamed to motoko/vetkeys/encrypted_notes_app_vetkd/backend/app.mo

Lines changed: 31 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,28 @@ import Text "mo:core/Text";
33
import Array "mo:core/Array";
44
import List "mo:core/List";
55
import PureList "mo:core/pure/List";
6-
import Iter "mo:core/Iter";
76
import Nat "mo:core/Nat";
87
import Nat8 "mo:core/Nat8";
98
import Bool "mo:core/Bool";
109
import Principal "mo:core/Principal";
1110
import Option "mo:core/Option";
12-
import Debug "mo:core/Debug";
1311
import Runtime "mo:core/Runtime";
1412
import Blob "mo:core/Blob";
1513
import Hex "./utils/Hex";
1614

1715
// Declare a shared actor class
1816
// Bind the caller and the initializer
19-
shared ({ caller = initializer }) persistent actor class (keyName: Text) {
17+
shared ({ caller = initializer }) actor class (keyName: Text) {
2018

21-
// Currently, a single canister smart contract is limited to 4 GB of heap size.
22-
// For the current limits see https://internetcomputer.org/docs/current/developer-docs/production/resource-limits.
19+
// Currently, a single canister is limited to 4 GB of heap size.
20+
// For the current limits see https://docs.internetcomputer.org/references/resource-limits.
2321
// To ensure that our canister does not exceed the limit, we put various restrictions (e.g., max number of users) in place.
2422
// This should keep us well below a memory usage of 2 GB because
2523
// up to 2x memory may be needed for data serialization during canister upgrades.
2624
// This is sufficient for this proof-of-concept, but in a production environment the actual
2725
// memory usage must be calculated or monitored and the various restrictions adapted accordingly.
2826

29-
// Define dapp limits - important for security assurance
27+
// Define app limits - important for security assurance
3028
private transient let MAX_USERS = 500;
3129
private transient let MAX_NOTES_PER_USER = 200;
3230
private transient let MAX_NOTE_CHARS = 1000;
@@ -41,40 +39,30 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) {
4139
// Here we assume that the notes are encrypted end-
4240
// to-end by the front-end (at client side).
4341
public type EncryptedNote = {
44-
encrypted_text : Text;
42+
encryptedText : Text;
4543
id : Nat;
4644
owner : PrincipalName;
4745
// Principals with whom this note is shared. Does not include the owner.
4846
// Needed to be able to efficiently show in the UI with whom this note is shared.
4947
users : [PrincipalName];
5048
};
5149

52-
// Define private fields
53-
// Stable actor fields are automatically retained across canister upgrades.
54-
// See https://internetcomputer.org/docs/current/motoko/main/upgrades/
50+
// Define private fields.
51+
// Actor fields are automatically retained across canister upgrades unless
52+
// declared `transient` (enhanced orthogonal persistence), so no `preupgrade`/
53+
// `postupgrade` hooks are needed to persist the state below.
54+
//
55+
// See https://docs.internetcomputer.org/guides/canister-management/lifecycle/#upgrade-a-canister
5556

5657
// Design choice: Use globally unique note identifiers for all users.
57-
//
58-
// The keyword `stable` makes this (scalar) variable keep its value across canister upgrades.
59-
//
60-
// See https://internetcomputer.org/docs/current/developer-docs/setup/manage-canisters#upgrade-a-canister
6158
private var nextNoteId : Nat = 1;
6259

6360
// Store notes by their ID, so that note-specific encryption keys can be derived.
64-
private transient var notesById = Map.empty<NoteId, EncryptedNote>();
61+
private var notesById = Map.empty<NoteId, EncryptedNote>();
6562
// Store which note IDs are owned by a particular principal
66-
private transient var noteIdsByOwner = Map.empty<PrincipalName, PureList.List<NoteId>>();
63+
private var noteIdsByOwner = Map.empty<PrincipalName, PureList.List<NoteId>>();
6764
// Store which notes are shared with a particular principal. Does not include the owner, as this is tracked by `noteIdsByOwner`.
68-
private transient var noteIdsByUser = Map.empty<PrincipalName, PureList.List<NoteId>>();
69-
70-
// While accessing _heap_ data is more efficient, we use the following _stable memory_
71-
// as a buffer to preserve data across canister upgrades.
72-
// Stable memory is currently 96GB. For the current limits see
73-
// https://internetcomputer.org/docs/current/developer-docs/production/resource-limits.
74-
// See also: [preupgrade], [postupgrade]
75-
private var stable_notesById : [(NoteId, EncryptedNote)] = [];
76-
private var stable_noteIdsByOwner : [(PrincipalName, PureList.List<NoteId>)] = [];
77-
private var stable_noteIdsByUser : [(PrincipalName, PureList.List<NoteId>)] = [];
65+
private var noteIdsByUser = Map.empty<PrincipalName, PureList.List<NoteId>>();
7866

7967
// Utility function that helps writing assertion-driven code more concisely.
8068
private func expect<T>(opt : ?T, violation_msg : Text) : T {
@@ -99,7 +87,7 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) {
9987
// Shared functions, i.e., those specified with [shared], are
10088
// accessible to remote callers.
10189
// The extra parameter [caller] is the caller's principal
102-
// See https://internetcomputer.org/docs/current/motoko/main/actors-async
90+
// See https://docs.internetcomputer.org/languages/motoko/fundamentals/actors/actors-async
10391

10492
// Add new empty note for this [caller].
10593
//
@@ -109,13 +97,13 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) {
10997
// [caller] is the anonymous identity
11098
// [caller] already has [MAX_NOTES_PER_USER] notes
11199
// This is the first note for [caller] and [MAX_USERS] is exceeded
112-
public shared ({ caller }) func create_note() : async NoteId {
100+
public shared ({ caller }) func createNote() : async NoteId {
113101
assert not Principal.isAnonymous(caller);
114102
let owner = Principal.toText(caller);
115103

116104
let newNote : EncryptedNote = {
117105
id = nextNoteId;
118-
encrypted_text = "";
106+
encryptedText = "";
119107
owner = owner;
120108
users = [];
121109
};
@@ -142,17 +130,17 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) {
142130
// Note that this method is declared as an *update* call (see `shared`) rather than *query*.
143131
//
144132
// While queries are significantly faster than updates, they are not certified by the IC.
145-
// Thus, we avoid using queries throughout this dapp, ensuring that the result of our
133+
// Thus, we avoid using queries throughout this app, ensuring that the result of our
146134
// functions gets through consensus. Otherwise, this function could e.g. omit some notes
147-
// if it got executed by a malicious node. (To make the dapp more efficient, one could
135+
// if it got executed by a malicious node. (To make the app more efficient, one could
148136
// use an approach in which both queries and updates are combined.)
149-
// See https://internetcomputer.org/docs/current/concepts/canisters-code#query-and-update-methods
137+
// See https://docs.internetcomputer.org/guides/canister-calls/calling-from-clients/#query-vs-update-calls
150138
//
151139
// Returns:
152140
// Future of array of EncryptedNote
153141
// Traps:
154142
// [caller] is the anonymous identity
155-
public shared ({ caller }) func get_notes() : async [EncryptedNote] {
143+
public shared ({ caller }) func getNotes() : async [EncryptedNote] {
156144
assert not Principal.isAnonymous(caller);
157145
let user = Principal.toText(caller);
158146

@@ -175,24 +163,24 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) {
175163
List.toArray(buf);
176164
};
177165

178-
// Replaces the encrypted text of note with ID [id] with [encrypted_text].
166+
// Replaces the encrypted text of note with ID [id] with [encryptedText].
179167
//
180168
// Returns:
181169
// Future of unit
182170
// Traps:
183171
// [caller] is the anonymous identity
184172
// note with ID [id] does not exist
185173
// [caller] is not the note's owner and not a user with whom the note is shared
186-
// [encrypted_text] exceeds [MAX_NOTE_CHARS]
187-
public shared ({ caller }) func update_note(id : NoteId, encrypted_text : Text) : async () {
174+
// [encryptedText] exceeds [MAX_NOTE_CHARS]
175+
public shared ({ caller }) func updateNote(id : NoteId, encryptedText : Text) : async () {
188176
assert not Principal.isAnonymous(caller);
189177
let caller_text = Principal.toText(caller);
190178
let (?note_to_update) = Map.get(notesById, Nat.compare, id) else Runtime.trap("note with id " # Nat.toText(id) # "not found");
191179
if (not is_authorized(caller_text, note_to_update)) {
192180
Runtime.trap("unauthorized");
193181
};
194-
assert note_to_update.encrypted_text.size() <= MAX_NOTE_CHARS;
195-
ignore Map.insert(notesById, Nat.compare, id, { note_to_update with encrypted_text });
182+
assert note_to_update.encryptedText.size() <= MAX_NOTE_CHARS;
183+
ignore Map.insert(notesById, Nat.compare, id, { note_to_update with encryptedText });
196184
};
197185

198186
// Shares the note with ID [note_id] with the [user].
@@ -204,7 +192,7 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) {
204192
// [caller] is the anonymous identity
205193
// note with ID [id] does not exist
206194
// [caller] is not the note's owner
207-
public shared ({ caller }) func add_user(note_id : NoteId, user : PrincipalName) : async () {
195+
public shared ({ caller }) func addUser(note_id : NoteId, user : PrincipalName) : async () {
208196
assert not Principal.isAnonymous(caller);
209197
let caller_text = Principal.toText(caller);
210198
let (?note) = Map.get(notesById, Nat.compare, note_id) else Runtime.trap("note with id " # Nat.toText(note_id) # "not found");
@@ -239,7 +227,7 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) {
239227
// [caller] is the anonymous identity
240228
// note with ID [id] does not exist
241229
// [caller] is not the note's owner
242-
public shared ({ caller }) func remove_user(note_id : NoteId, user : PrincipalName) : async () {
230+
public shared ({ caller }) func removeUser(note_id : NoteId, user : PrincipalName) : async () {
243231
assert not Principal.isAnonymous(caller);
244232
let caller_text = Principal.toText(caller);
245233
let (?note) = Map.get(notesById, Nat.compare, note_id) else Runtime.trap("note with id " # Nat.toText(note_id) # "not found");
@@ -270,7 +258,7 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) {
270258
// [caller] is the anonymous identity
271259
// note with ID [id] does not exist
272260
// [caller] is not the note's owner
273-
public shared ({ caller }) func delete_note(note_id : NoteId) : async () {
261+
public shared ({ caller }) func deleteNote(note_id : NoteId) : async () {
274262
assert not Principal.isAnonymous(caller);
275263
let caller_text = Principal.toText(caller);
276264
let (?note_to_delete) = Map.get(notesById, Nat.compare, note_id) else Runtime.trap("note with id " # Nat.toText(note_id) # "not found");
@@ -322,7 +310,7 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) {
322310

323311
transient let management_canister : VETKD_API = actor ("aaaaa-aa");
324312

325-
public shared func symmetric_key_verification_key_for_note() : async Text {
313+
public shared func symmetricKeyVerificationKeyForNote() : async Text {
326314
let { public_key } = await management_canister.vetkd_public_key({
327315
canister_id = null;
328316
context = Text.encodeUtf8("note_symmetric_key");
@@ -331,7 +319,7 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) {
331319
Hex.encode(Blob.toArray(public_key));
332320
};
333321

334-
public shared ({ caller }) func encrypted_symmetric_key_for_note(note_id : NoteId, transport_public_key : Blob) : async Text {
322+
public shared ({ caller }) func encryptedSymmetricKeyForNote(note_id : NoteId, transport_public_key : Blob) : async Text {
335323
let caller_text = Principal.toText(caller);
336324
let (?note) = Map.get(notesById, Nat.compare, note_id) else Runtime.trap("note with id " # Nat.toText(note_id) # "not found");
337325
if (not is_authorized(caller_text, note)) {
@@ -361,42 +349,4 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) {
361349
};
362350
Array.tabulate<Nat8>(len, ith_byte);
363351
};
364-
365-
// Below, we implement the upgrade hooks for our canister.
366-
// See https://internetcomputer.org/docs/current/motoko/main/upgrades/
367-
368-
// The work required before a canister upgrade begins.
369-
system func preupgrade() {
370-
Debug.print("Starting pre-upgrade hook...");
371-
stable_notesById := Iter.toArray(Map.entries(notesById));
372-
stable_noteIdsByOwner := Iter.toArray(Map.entries(noteIdsByOwner));
373-
stable_noteIdsByUser := Iter.toArray(Map.entries(noteIdsByUser));
374-
Debug.print("pre-upgrade finished.");
375-
};
376-
377-
// The work required after a canister upgrade ends.
378-
// See [nextNoteId], [stable_notesByUser]
379-
system func postupgrade() {
380-
Debug.print("Starting post-upgrade hook...");
381-
382-
notesById := Map.fromIter(
383-
stable_notesById.values(),
384-
Nat.compare,
385-
);
386-
stable_notesById := [];
387-
388-
noteIdsByOwner := Map.fromIter(
389-
stable_noteIdsByOwner.values(),
390-
Text.compare,
391-
);
392-
stable_noteIdsByOwner := [];
393-
394-
noteIdsByUser := Map.fromIter(
395-
stable_noteIdsByUser.values(),
396-
Text.compare,
397-
);
398-
stable_noteIdsByUser := [];
399-
400-
Debug.print("post-upgrade finished.");
401-
};
402352
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
type _anon_class_17_1 =
2+
service {
3+
addUser: (note_id: NoteId, user: PrincipalName) -> ();
4+
createNote: () -> (NoteId);
5+
deleteNote: (note_id: NoteId) -> ();
6+
encryptedSymmetricKeyForNote: (note_id: NoteId, transport_public_key:
7+
blob) -> (text);
8+
getNotes: () -> (vec EncryptedNote);
9+
removeUser: (note_id: NoteId, user: PrincipalName) -> ();
10+
symmetricKeyVerificationKeyForNote: () -> (text);
11+
updateNote: (id: NoteId, encryptedText: text) -> ();
12+
whoami: () -> (text);
13+
};
14+
type PrincipalName = text;
15+
type NoteId = nat;
16+
type EncryptedNote =
17+
record {
18+
encryptedText: text;
19+
id: nat;
20+
owner: PrincipalName;
21+
users: vec PrincipalName;
22+
};
23+
service : (keyName: text) -> _anon_class_17_1

rust/vetkeys/encrypted_notes_app_vetkd/motoko/backend/utils/Hex.mo renamed to motoko/vetkeys/encrypted_notes_app_vetkd/backend/utils/Hex.mo

File renamed without changes.

0 commit comments

Comments
 (0)