diff --git a/.github/workflows/vetkeys-encrypted-notes-app-vetkd.yml b/.github/workflows/vetkeys-encrypted-notes-app-vetkd.yml index a166f927a5..15035bc8fd 100644 --- a/.github/workflows/vetkeys-encrypted-notes-app-vetkd.yml +++ b/.github/workflows/vetkeys-encrypted-notes-app-vetkd.yml @@ -6,6 +6,7 @@ on: - master pull_request: paths: + - motoko/vetkeys/encrypted_notes_app_vetkd/** - rust/vetkeys/encrypted_notes_app_vetkd/** - .github/workflows/vetkeys-encrypted-notes-app-vetkd.yml @@ -14,23 +15,28 @@ concurrency: cancel-in-progress: true jobs: - rust: + motoko: runs-on: ubuntu-24.04 - container: ghcr.io/dfinity/icp-dev-env-rust:1.0.1 + container: ghcr.io/dfinity/icp-dev-env-motoko:1.0.1 env: ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - name: Deploy Encrypted Notes App Vetkd Rust - working-directory: rust/vetkeys/encrypted_notes_app_vetkd/rust - run: icp network start -d && icp deploy - motoko: + - name: Deploy + working-directory: motoko/vetkeys/encrypted_notes_app_vetkd + run: | + icp network start -d + icp deploy + + rust: runs-on: ubuntu-24.04 - container: ghcr.io/dfinity/icp-dev-env-motoko:1.0.1 + container: ghcr.io/dfinity/icp-dev-env-rust:1.0.1 env: ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - name: Deploy Encrypted Notes App Vetkd Motoko - working-directory: rust/vetkeys/encrypted_notes_app_vetkd/motoko - run: icp network start -d && icp deploy + - name: Deploy + working-directory: rust/vetkeys/encrypted_notes_app_vetkd + run: | + icp network start -d + icp deploy diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/README.md b/motoko/vetkeys/encrypted_notes_app_vetkd/README.md new file mode 100644 index 0000000000..dc0197c2ab --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/README.md @@ -0,0 +1,77 @@ +# Encrypted Notes: vetKD (Motoko) + +[View this sample's code on GitHub](https://github.com/dfinity/examples/tree/master/motoko/vetkeys/encrypted_notes_app_vetkd) + +Also available in: [Rust](../../../rust/vetkeys/encrypted_notes_app_vetkd) + +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. + +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. + +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. + +## Build and deploy from the command line + +### Prerequisites + +- Install [Node.js](https://nodejs.org/en/download/) +- Install [icp-cli](https://cli.internetcomputer.org): `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm` +- Install [ic-mops](https://mops.one): `npm install -g ic-mops` + +### Install + +```bash +git clone https://github.com/dfinity/examples +cd examples/motoko/vetkeys/encrypted_notes_app_vetkd +``` + +### Deploy + +```bash +icp network start -d +icp deploy +``` + +Open the frontend URL printed by `icp deploy`. + +To run the frontend in development mode with hot reloading (after `icp deploy`): + +```bash +npm run dev +``` + +When done, stop the local network to free up the port for other projects: + +```bash +icp network stop +``` + +## Example components + +### Backend (`backend/`) + +A single Motoko canister that stores encrypted notes. It is deployed automatically with `icp deploy`. + +### Frontend (`frontend/`) + +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. + +## Limitations + +This example app does not implement key rotation, which is strongly recommended in a production environment. + +## Troubleshooting + +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. + +## API level + +This example intentionally uses the **raw vetKD management canister API** (`encryptedSymmetricKeyForNote`, `symmetricKeyVerificationKeyForNote`) to demonstrate how vetKD works at the protocol level. + +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. + +## Additional resources + +- **[What are VetKeys](https://docs.internetcomputer.org/concepts/vetkeys)** — more information about VetKeys and VetKD. +- [Security checklist for this example](security-checklist.md) +- [Security best practices](https://docs.internetcomputer.org/guides/security/overview/) diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/motoko/backend/main.mo b/motoko/vetkeys/encrypted_notes_app_vetkd/backend/app.mo similarity index 76% rename from rust/vetkeys/encrypted_notes_app_vetkd/motoko/backend/main.mo rename to motoko/vetkeys/encrypted_notes_app_vetkd/backend/app.mo index 01abfd5c89..1d0dcec0c4 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/motoko/backend/main.mo +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/backend/app.mo @@ -3,30 +3,28 @@ import Text "mo:core/Text"; import Array "mo:core/Array"; import List "mo:core/List"; import PureList "mo:core/pure/List"; -import Iter "mo:core/Iter"; import Nat "mo:core/Nat"; import Nat8 "mo:core/Nat8"; import Bool "mo:core/Bool"; import Principal "mo:core/Principal"; import Option "mo:core/Option"; -import Debug "mo:core/Debug"; import Runtime "mo:core/Runtime"; import Blob "mo:core/Blob"; import Hex "./utils/Hex"; // Declare a shared actor class // Bind the caller and the initializer -shared ({ caller = initializer }) persistent actor class (keyName: Text) { +shared ({ caller = initializer }) actor class (keyName: Text) { - // 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. + // Currently, a single canister is limited to 4 GB of heap size. + // For the current limits see https://docs.internetcomputer.org/references/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 + // Define app limits - important for security assurance private transient let MAX_USERS = 500; private transient let MAX_NOTES_PER_USER = 200; private transient let MAX_NOTE_CHARS = 1000; @@ -41,7 +39,7 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) { // Here we assume that the notes are encrypted end- // to-end by the front-end (at client side). public type EncryptedNote = { - encrypted_text : Text; + encryptedText : Text; id : Nat; owner : PrincipalName; // Principals with whom this note is shared. Does not include the owner. @@ -49,32 +47,22 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) { users : [PrincipalName]; }; - // Define private fields - // Stable actor fields are automatically retained across canister upgrades. - // See https://internetcomputer.org/docs/current/motoko/main/upgrades/ + // Define private fields. + // Actor fields are automatically retained across canister upgrades unless + // declared `transient` (enhanced orthogonal persistence), so no `preupgrade`/ + // `postupgrade` hooks are needed to persist the state below. + // + // See https://docs.internetcomputer.org/guides/canister-management/lifecycle/#upgrade-a-canister // 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 var nextNoteId : Nat = 1; // Store notes by their ID, so that note-specific encryption keys can be derived. - private transient var notesById = Map.empty(); + private var notesById = Map.empty(); // Store which note IDs are owned by a particular principal - private transient var noteIdsByOwner = Map.empty>(); + private var noteIdsByOwner = Map.empty>(); // Store which notes are shared with a particular principal. Does not include the owner, as this is tracked by `noteIdsByOwner`. - private transient var noteIdsByUser = Map.empty>(); - - // 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 var stable_notesById : [(NoteId, EncryptedNote)] = []; - private var stable_noteIdsByOwner : [(PrincipalName, PureList.List)] = []; - private var stable_noteIdsByUser : [(PrincipalName, PureList.List)] = []; + private var noteIdsByUser = Map.empty>(); // Utility function that helps writing assertion-driven code more concisely. private func expect(opt : ?T, violation_msg : Text) : T { @@ -99,7 +87,7 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) { // 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 + // See https://docs.internetcomputer.org/languages/motoko/fundamentals/actors/actors-async // Add new empty note for this [caller]. // @@ -109,13 +97,13 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) { // [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 { + public shared ({ caller }) func createNote() : async NoteId { assert not Principal.isAnonymous(caller); let owner = Principal.toText(caller); let newNote : EncryptedNote = { id = nextNoteId; - encrypted_text = ""; + encryptedText = ""; owner = owner; users = []; }; @@ -142,17 +130,17 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) { // 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 + // Thus, we avoid using queries throughout this app, 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 + // if it got executed by a malicious node. (To make the app 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 + // See https://docs.internetcomputer.org/guides/canister-calls/calling-from-clients/#query-vs-update-calls // // Returns: // Future of array of EncryptedNote // Traps: // [caller] is the anonymous identity - public shared ({ caller }) func get_notes() : async [EncryptedNote] { + public shared ({ caller }) func getNotes() : async [EncryptedNote] { assert not Principal.isAnonymous(caller); let user = Principal.toText(caller); @@ -175,7 +163,7 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) { List.toArray(buf); }; - // Replaces the encrypted text of note with ID [id] with [encrypted_text]. + // Replaces the encrypted text of note with ID [id] with [encryptedText]. // // Returns: // Future of unit @@ -183,16 +171,16 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) { // [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 () { + // [encryptedText] exceeds [MAX_NOTE_CHARS] + public shared ({ caller }) func updateNote(id : NoteId, encryptedText : Text) : async () { assert not Principal.isAnonymous(caller); let caller_text = Principal.toText(caller); let (?note_to_update) = Map.get(notesById, Nat.compare, id) else Runtime.trap("note with id " # Nat.toText(id) # "not found"); if (not is_authorized(caller_text, note_to_update)) { Runtime.trap("unauthorized"); }; - assert note_to_update.encrypted_text.size() <= MAX_NOTE_CHARS; - ignore Map.insert(notesById, Nat.compare, id, { note_to_update with encrypted_text }); + assert note_to_update.encryptedText.size() <= MAX_NOTE_CHARS; + ignore Map.insert(notesById, Nat.compare, id, { note_to_update with encryptedText }); }; // Shares the note with ID [note_id] with the [user]. @@ -204,7 +192,7 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) { // [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 () { + public shared ({ caller }) func addUser(note_id : NoteId, user : PrincipalName) : async () { assert not Principal.isAnonymous(caller); let caller_text = Principal.toText(caller); 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) { // [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 () { + public shared ({ caller }) func removeUser(note_id : NoteId, user : PrincipalName) : async () { assert not Principal.isAnonymous(caller); let caller_text = Principal.toText(caller); 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) { // [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 () { + public shared ({ caller }) func deleteNote(note_id : NoteId) : async () { assert not Principal.isAnonymous(caller); let caller_text = Principal.toText(caller); 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) { transient let management_canister : VETKD_API = actor ("aaaaa-aa"); - public shared func symmetric_key_verification_key_for_note() : async Text { + public shared func symmetricKeyVerificationKeyForNote() : async Text { let { public_key } = await management_canister.vetkd_public_key({ canister_id = null; context = Text.encodeUtf8("note_symmetric_key"); @@ -331,7 +319,7 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) { Hex.encode(Blob.toArray(public_key)); }; - public shared ({ caller }) func encrypted_symmetric_key_for_note(note_id : NoteId, transport_public_key : Blob) : async Text { + public shared ({ caller }) func encryptedSymmetricKeyForNote(note_id : NoteId, transport_public_key : Blob) : async Text { let caller_text = Principal.toText(caller); let (?note) = Map.get(notesById, Nat.compare, note_id) else Runtime.trap("note with id " # Nat.toText(note_id) # "not found"); if (not is_authorized(caller_text, note)) { @@ -361,42 +349,4 @@ shared ({ caller = initializer }) persistent actor class (keyName: Text) { }; Array.tabulate(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(Map.entries(notesById)); - stable_noteIdsByOwner := Iter.toArray(Map.entries(noteIdsByOwner)); - stable_noteIdsByUser := Iter.toArray(Map.entries(noteIdsByUser)); - 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( - stable_notesById.values(), - Nat.compare, - ); - stable_notesById := []; - - noteIdsByOwner := Map.fromIter( - stable_noteIdsByOwner.values(), - Text.compare, - ); - stable_noteIdsByOwner := []; - - noteIdsByUser := Map.fromIter( - stable_noteIdsByUser.values(), - Text.compare, - ); - stable_noteIdsByUser := []; - - Debug.print("post-upgrade finished."); - }; }; diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/backend/backend.did b/motoko/vetkeys/encrypted_notes_app_vetkd/backend/backend.did new file mode 100644 index 0000000000..ce3f9b9f93 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/backend/backend.did @@ -0,0 +1,23 @@ +type _anon_class_17_1 = + service { + addUser: (note_id: NoteId, user: PrincipalName) -> (); + createNote: () -> (NoteId); + deleteNote: (note_id: NoteId) -> (); + encryptedSymmetricKeyForNote: (note_id: NoteId, transport_public_key: + blob) -> (text); + getNotes: () -> (vec EncryptedNote); + removeUser: (note_id: NoteId, user: PrincipalName) -> (); + symmetricKeyVerificationKeyForNote: () -> (text); + updateNote: (id: NoteId, encryptedText: text) -> (); + whoami: () -> (text); + }; +type PrincipalName = text; +type NoteId = nat; +type EncryptedNote = + record { + encryptedText: text; + id: nat; + owner: PrincipalName; + users: vec PrincipalName; + }; +service : (keyName: text) -> _anon_class_17_1 diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/motoko/backend/utils/Hex.mo b/motoko/vetkeys/encrypted_notes_app_vetkd/backend/utils/Hex.mo similarity index 100% rename from rust/vetkeys/encrypted_notes_app_vetkd/motoko/backend/utils/Hex.mo rename to motoko/vetkeys/encrypted_notes_app_vetkd/backend/utils/Hex.mo diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/.gitignore b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/.gitignore new file mode 100644 index 0000000000..061e2e66e1 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +src/bindings/ diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/.npmrc b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/.npmrc new file mode 100644 index 0000000000..521a9f7c07 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/index.html b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/index.html new file mode 100644 index 0000000000..a494e91eac --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Encrypted Notes + + + + + + diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/package.json b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/package.json new file mode 100644 index 0000000000..a47e8cdc67 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "frontend", + "private": true, + "type": "module", + "scripts": { + "prebuild": "npm i --include=dev", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@icp-sdk/bindgen": "~0.2.2", + "@sveltejs/vite-plugin-svelte": "^3.0.2", + "@tsconfig/svelte": "^5.0.4", + "@types/node": "^24.0.10", + "autoprefixer": "^10.4.2", + "postcss": "^8.4.31", + "svelte": "^4.2.19", + "svelte-check": "^3.8.6", + "tailwindcss": "^3.0.17", + "tslib": "^2.8.1", + "typescript": "~5.7.2", + "vite": "^5.4.21" + }, + "dependencies": { + "@icp-sdk/auth": "^7.1.0", + "@icp-sdk/core": "^5.4.0", + "@icp-sdk/vetkeys": "^0.5.0-beta.0", + "daisyui": "^1.25.4", + "idb-keyval": "6.2.1", + "isomorphic-dompurify": "^2.25.0", + "svelte-icons": "^2.1.0", + "svelte-spa-router": "^4.0.1", + "typewriter-editor": "^0.6.45" + } +} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/postcss.config.mjs b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/postcss.config.mjs new file mode 100644 index 0000000000..8409575db5 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/postcss.config.mjs @@ -0,0 +1,6 @@ +import tailwindcss from 'tailwindcss'; +import autoprefixer from 'autoprefixer'; + +export default { + plugins: [tailwindcss(), autoprefixer()], +}; diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/.ic-assets.json5 b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/.ic-assets.json5 new file mode 100644 index 0000000000..6d07243f3f --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/.ic-assets.json5 @@ -0,0 +1,10 @@ +[ + { + match: "**/*", + security_policy: "hardened", + headers: { + "Content-Security-Policy": "default-src 'self';script-src 'self';connect-src 'self' http://localhost:* https://icp0.io https://*.icp0.io https://icp-api.io;img-src 'self';style-src * 'unsafe-inline';object-src 'none';base-uri 'self';frame-ancestors 'none';form-action 'self';upgrade-insecure-requests;", + }, + allow_raw_access: false + }, +] diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/favicon.png b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/favicon.png new file mode 100644 index 0000000000..7e6f5eb5a2 Binary files /dev/null and b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/favicon.png differ diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_label-stripe-dark-text.png b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_label-stripe-dark-text.png new file mode 100644 index 0000000000..1a227a2b05 Binary files /dev/null and b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_label-stripe-dark-text.png differ diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_label-stripe-white-text.png b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_label-stripe-white-text.png new file mode 100644 index 0000000000..e1da198555 Binary files /dev/null and b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_label-stripe-white-text.png differ diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_transparent-dark-text.png b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_transparent-dark-text.png new file mode 100644 index 0000000000..90029c464b Binary files /dev/null and b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_transparent-dark-text.png differ diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_transparent-white-text.png b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_transparent-white-text.png new file mode 100644 index 0000000000..f1a2d80812 Binary files /dev/null and b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/public/img/ic-badge-powered-by-crypto_transparent-white-text.png differ diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/App.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/App.svelte new file mode 100644 index 0000000000..b3b5d6eec9 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/App.svelte @@ -0,0 +1,14 @@ + + +{#if $auth.state === 'initialized'} + +{:else} + +{/if} + + diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/app.css b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/app.css new file mode 100644 index 0000000000..b5c61c9567 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/app.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Disclaimer.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Disclaimer.svelte new file mode 100644 index 0000000000..16ce216fca --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Disclaimer.svelte @@ -0,0 +1,26 @@ + + +{#if !isDismissed} +
+

+ +

+ + +
+{/if} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/DisclaimerCopy.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/DisclaimerCopy.svelte new file mode 100644 index 0000000000..2bf836e940 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/DisclaimerCopy.svelte @@ -0,0 +1,3 @@ +Disclaimer: This sample app is intended exclusively for experimental +purpose. You are advised not to use this app for storing your critical data such +as keys or passwords. diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/EditNote.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/EditNote.svelte new file mode 100644 index 0000000000..1f7a041d9a --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/EditNote.svelte @@ -0,0 +1,162 @@ + + +{#if editedNote} +
+ Edit note + +
+
+ {#if $notesStore.state === 'loaded'} + + addTag(e.detail)} + on:remove={(e) => removeTag(e.detail)} + disabled={updating || deleting} + /> + +
+ + {:else if $notesStore.state === 'loading'} + Loading notes... + {/if} +
+{:else} +
+ Edit note +
+
+ {#if $notesStore.state === 'loading'} + + Loading note... + {:else if $notesStore.state === 'loaded'} +
Could not find note.
+ {/if} +
+{/if} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Header.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Header.svelte new file mode 100644 index 0000000000..922f3f8c0b --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Header.svelte @@ -0,0 +1,25 @@ + diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Hero.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Hero.svelte new file mode 100644 index 0000000000..19aa54e29c --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Hero.svelte @@ -0,0 +1,71 @@ + + +
+
+
+

+ Encrypted Notes +

+

+ Your private notes on the Internet Computer. +

+

+ A safe place to store your personal lists, thoughts, ideas or + passphrases and much more... +

+ + {#if auth.state === 'initializing-auth' || auth.state === 'initializing-crypto'} +
+ + Initializing... +
+ {:else if auth.state === 'synchronizing'} +
+ + Synchronizing... Please keep the app open on a device that's already added. +
+ {:else if auth.state === 'anonymous'} + + {:else if auth.state === 'error'} +
An error occurred.
+ {/if} + +
+ +
+
+
+
+ + Powered by the Internet Computer +
+
diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/LayoutAuthenticated.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/LayoutAuthenticated.svelte new file mode 100644 index 0000000000..acf0e17c7f --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/LayoutAuthenticated.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/NewNote.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/NewNote.svelte new file mode 100644 index 0000000000..31e71eda2b --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/NewNote.svelte @@ -0,0 +1,93 @@ + + + + +
+ New note +
+ +
+ + addTag(e.detail)} + on:remove={(e) => removeTag(e.detail)} + disabled={creating} + /> + +
+ + diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Note.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Note.svelte new file mode 100644 index 0000000000..bec2d02a83 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Note.svelte @@ -0,0 +1,46 @@ + + + +
+

+ {#if note.title} + {note.title} + {:else} + Unnamed note + {/if} +

+ {contentSummary} + {#if note.tags.length > 0} +
+ {#each note.tags as tag} + + {/each} +
+ {/if} +
+
diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/NoteEditor.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/NoteEditor.svelte new file mode 100644 index 0000000000..120bbe78f3 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/NoteEditor.svelte @@ -0,0 +1,68 @@ + + + +
+ + + + +
+
+ +
+ + diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Notes.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Notes.svelte new file mode 100644 index 0000000000..7b142660a7 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Notes.svelte @@ -0,0 +1,75 @@ + + +
+ Your notes + + {#if $notesStore.state === 'loaded' && $notesStore.list.length > 0} + New Note + {/if} + +
+
+ {#if $notesStore.state === 'loading'} + + Loading notes... + {:else if $notesStore.state === 'loaded'} + {#if $notesStore.list.length > 0} +
+ +
+ +
+ {#each filteredNotes as note (note.id)} + (filter = e.detail)} /> + {/each} +
+ {:else} +
You don't have any notes.
+ + {/if} + {:else if $notesStore.state === 'error'} +
Could not load notes.
+ {/if} +
diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Notifications.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Notifications.svelte new file mode 100644 index 0000000000..85528b1d15 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Notifications.svelte @@ -0,0 +1,23 @@ + + +
+ {#each $notifications as n (n.id)} +
+

{n.message}

+
+ {/each} +
diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/SharingEditor.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/SharingEditor.svelte new file mode 100644 index 0000000000..086758babb --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/SharingEditor.svelte @@ -0,0 +1,128 @@ + + +
+

Users

+ {#if ownedByMe} +

+ Add users by their principal to allow them editing the note. +

+ {:else} +

+ This note is shared with you. It is owned + by {editedNote.owner}. +

+

Users with whom the owner shared the note:

+ {/if} +
+ {#each editedNote.users as sharing} + + {/each} + + +
+
diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/SidebarLayout.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/SidebarLayout.svelte new file mode 100644 index 0000000000..d6d27f2bfd --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/SidebarLayout.svelte @@ -0,0 +1,75 @@ + + +
+ +
+
+ +
+ +
+
+
+
diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Spinner.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Spinner.svelte new file mode 100644 index 0000000000..fd7812cce0 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/Spinner.svelte @@ -0,0 +1,3 @@ + diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/TagEditor.svelte b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/TagEditor.svelte new file mode 100644 index 0000000000..0b4df389e8 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/components/TagEditor.svelte @@ -0,0 +1,74 @@ + + +
+ {#each tags as tag} + + {/each} + + +
diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/global.d.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/global.d.ts new file mode 100644 index 0000000000..0e7296906a --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/global.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/actor.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/actor.ts new file mode 100644 index 0000000000..d953820035 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/actor.ts @@ -0,0 +1,19 @@ +import { HttpAgent } from "@icp-sdk/core/agent"; +import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; +import { createActor as createEncryptedNotesActor, type Backend } from "../bindings/backend"; + +export type BackendActor = Backend; + +const canisterEnv = safeGetCanisterEnv<{ + "PUBLIC_CANISTER_ID:backend": string; +}>(); + +export async function createActor(options?: { identity?: any }): Promise { + const canisterId = canisterEnv?.["PUBLIC_CANISTER_ID:backend"]; + const agent = await HttpAgent.create({ + identity: options?.identity, + host: window.location.origin, + rootKey: canisterEnv?.IC_ROOT_KEY, + }); + return createEncryptedNotesActor(canisterId, { agent }); +} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/crypto.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/crypto.ts new file mode 100644 index 0000000000..ac8a0e785f --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/crypto.ts @@ -0,0 +1,78 @@ +import type { BackendActor } from './actor'; +import { get, set } from 'idb-keyval'; +import * as vetkd from "@icp-sdk/vetkeys"; + +export class CryptoService { + private keyMaterialCache = new Map(); + + constructor(private actor: BackendActor) {} + + public async encryptWithNoteKey(note_id: bigint, owner: string, data: string): Promise { + const keyMaterial = await this.fetchNoteKeyMaterial(note_id, owner); + const associatedData = buildInput(note_id, owner); + const encrypted = await keyMaterial.encryptMessage(data, "note-key", associatedData); + return String.fromCharCode(...encrypted); + } + + public async decryptWithNoteKey(note_id: bigint, owner: string, data: string): Promise { + const keyMaterial = await this.fetchNoteKeyMaterial(note_id, owner); + const associatedData = buildInput(note_id, owner); + const ciphertext = Uint8Array.from([...data].map(ch => ch.charCodeAt(0))); + const decrypted = await keyMaterial.decryptMessage(ciphertext, "note-key", associatedData); + return String.fromCharCode(...decrypted); + } + + private async fetchNoteKeyMaterial(note_id: bigint, owner: string): Promise { + const cacheKey = `${note_id}_${owner}`; + + // 1. In-memory cache (fastest, session-scoped) + const memCached = this.keyMaterialCache.get(cacheKey); + if (memCached) return memCached; + + // 2. IndexedDB cache (persisted across sessions via getCryptoKey/fromCryptoKey) + const storedCryptoKey: CryptoKey | undefined = await get([note_id.toString(), owner]); + if (storedCryptoKey) { + const keyMaterial = await vetkd.DerivedKeyMaterial.fromCryptoKey(storedCryptoKey); + this.keyMaterialCache.set(cacheKey, keyMaterial); + return keyMaterial; + } + + // 3. Fetch from canister (first access) + const tsk = vetkd.TransportSecretKey.random(); + const ek_bytes_hex = await this.actor.encryptedSymmetricKeyForNote(note_id, tsk.publicKeyBytes()); + const encryptedVetKey = vetkd.EncryptedVetKey.deserialize(hex_decode(ek_bytes_hex)); + const pk_bytes_hex = await this.actor.symmetricKeyVerificationKeyForNote(); + const dpk = vetkd.DerivedPublicKey.deserialize(hex_decode(pk_bytes_hex)); + const input = buildInput(note_id, owner); + const vetKey = encryptedVetKey.decryptAndVerify(tsk, dpk, input); + const keyMaterial = await vetKey.asDerivedKeyMaterial(); + + // Store the underlying non-extractable CryptoKey in IndexedDB (same pattern as EncryptedMaps) + await set([note_id.toString(), owner], keyMaterial.getCryptoKey()); + this.keyMaterialCache.set(cacheKey, keyMaterial); + return keyMaterial; + } +} + +function buildInput(note_id: bigint, owner: string): Uint8Array { + const note_id_bytes = bigintTo128BitBigEndianUint8Array(note_id); + const owner_utf8 = new TextEncoder().encode(owner); + const input = new Uint8Array(note_id_bytes.length + owner_utf8.length); + input.set(note_id_bytes); + input.set(owner_utf8, note_id_bytes.length); + return input; +} + +const hex_decode = (hexString: string) => + Uint8Array.from(hexString.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))); + +function bigintTo128BitBigEndianUint8Array(bn: bigint): Uint8Array { + var hex = BigInt(bn).toString(16); + while (hex.length < 32) { hex = '0' + hex; } + var len = hex.length / 2; + var u8 = new Uint8Array(len); + for (var i = 0, j = 0; i < len; i++, j += 2) { + u8[i] = parseInt(hex.slice(j, j + 2), 16); + } + return u8; +} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/enums.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/enums.ts new file mode 100644 index 0000000000..daf2b66906 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/enums.ts @@ -0,0 +1,8 @@ +export type KeysOfUnion = T extends T ? keyof T : never; + +export function enumIs( + p: EnumType, + key: KeysOfUnion +): p is T { + return (key as string) in p; +} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/note.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/note.ts new file mode 100644 index 0000000000..2d1fc21c97 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/note.ts @@ -0,0 +1,106 @@ +import type { EncryptedNote } from '../bindings/backend'; +import type { CryptoService } from './crypto'; +import type { Principal } from '@icp-sdk/core/principal'; + +export interface NoteModel { + id: bigint; + title: string; + content: string; + createdAt: number; + updatedAt: number; + tags: Array; + owner: string; + users: Array; +} + +type SerializableNoteModel = Omit; + +export function noteFromContent(content: string, tags: string[], self_principal: Principal): NoteModel { + const title = extractTitle(content); + const creationTime = Date.now(); + + return { + id: BigInt(0), + title, + content, + createdAt: creationTime, + updatedAt: creationTime, + tags, + owner: self_principal.toString(), + users: [""], + }; +} + +export async function serialize( + note: NoteModel, + cryptoService: CryptoService +): Promise { + const serializableNote: SerializableNoteModel = { + title: note.title, + content: note.content, + createdAt: note.createdAt, + updatedAt: note.updatedAt, + tags: note.tags, + }; + const encryptedNote = await cryptoService.encryptWithNoteKey( + note.id, + note.owner, + JSON.stringify(serializableNote) + ); + return { + id: note.id, + encryptedText: encryptedNote, + owner: note.owner, + users: note.users, + }; +} + +export async function deserialize( + enote: EncryptedNote, + cryptoService: CryptoService +): Promise { + const serializedNote = await cryptoService.decryptWithNoteKey(enote.id, enote.owner, enote.encryptedText); + const deserializedNote: SerializableNoteModel = JSON.parse(serializedNote); + return { + id: enote.id, + owner: enote.owner, + users: enote.users, + ...deserializedNote, + }; +} + +export function summarize(note: NoteModel, maxLength = 50) { + const div = document.createElement('div'); + div.innerHTML = note.content; + + let text = div.innerText; + const title = extractTitleFromDomEl(div); + if (title) { + text = text.replace(title, ''); + } + + return text.slice(0, maxLength) + (text.length > maxLength ? '...' : ''); +} + +function extractTitleFromDomEl(el: HTMLElement) { + const title = el.querySelector('h1'); + if (title) { + return title.innerText; + } + + const blockElements = el.querySelectorAll( + 'h1,h2,p,li' + ) as NodeListOf; + for (const el of blockElements) { + if (el.innerText?.trim().length > 0) { + return el.innerText.trim(); + } + } + return ''; +} + +export function extractTitle(html: string) { + const div = document.createElement('div'); + div.innerHTML = html; + return extractTitleFromDomEl(div); +} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/sleep.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/sleep.ts new file mode 100644 index 0000000000..0d7f188e17 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/main.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/main.ts new file mode 100644 index 0000000000..75e6d2f702 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/main.ts @@ -0,0 +1,10 @@ +import "./app.css"; +import App from "./App.svelte"; + +const init = async () => { + const app = new App({ + target: document.body, + }); +}; + +init(); \ No newline at end of file diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/auth.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/auth.ts new file mode 100644 index 0000000000..88f138897d --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/auth.ts @@ -0,0 +1,132 @@ +import { get, writable } from "svelte/store"; +import { type BackendActor, createActor } from "../lib/actor"; +import { AuthClient, LocalStorage } from "@icp-sdk/auth/client"; +import type { Principal } from "@icp-sdk/core/principal"; +import { CryptoService } from "../lib/crypto"; +import { showError } from "./notifications"; +import { push } from "svelte-spa-router"; + +export type AuthState = + | { state: "initializing-auth" } + | { state: "anonymous"; actor: BackendActor; client: AuthClient } + | { state: "initializing-crypto"; actor: BackendActor; client: AuthClient; principal: Principal } + | { state: "synchronizing"; actor: BackendActor; client: AuthClient; principal: Principal } + | { + state: "initialized"; + actor: BackendActor; + client: AuthClient; + principal: Principal; + crypto: CryptoService; + } + | { state: "error"; error: string }; + +export const auth = writable({ state: "initializing-auth" }); + +async function initAuth() { + const isLocal = + window.location.hostname === "localhost" || + window.location.hostname.endsWith(".localhost"); + // Workaround for https://github.com/dfinity/icp-js-auth/issues/120 + // IdbStorage has a race condition on localhost dev servers. LocalStorage + // avoids IDB on local but uses plain string storage (less secure), so + // production deployments keep the default secure IdbStorage + ECDSA key. + const client = new AuthClient({ + identityProvider: isLocal + ? "http://id.ai.localhost:8000/authorize" + : "https://id.ai/authorize", + ...(isLocal + ? { storage: new LocalStorage(), keyType: "Ed25519" as const } + : {}), + }); + if (client.isAuthenticated()) { + authenticate(client); + } else { + const actor = await createActor(); + auth.update(() => ({ + state: "anonymous", + actor, + client, + })); + } +} + +initAuth(); + +export async function login() { + const currentAuth = get(auth); + + if (currentAuth.state === "anonymous") { + await currentAuth.client.signIn(); + authenticate(currentAuth.client); + } +} + +export async function logout() { + const currentAuth = get(auth); + + if (currentAuth.state === "initialized") { + await currentAuth.client.signOut(); + const actor = await createActor(); + auth.update(() => ({ + state: "anonymous", + actor, + client: currentAuth.client, + })); + push("/"); + } +} + +export async function authenticate(client: AuthClient) { + handleSessionTimeout(); + + try { + const identity = await client.getIdentity(); + const principal = identity.getPrincipal(); + const actor = await createActor({ identity }); + + auth.update(() => ({ + state: "initializing-crypto", + actor, + client, + principal, + })); + + const cryptoService = new CryptoService(actor); + + auth.update(() => ({ + state: "initialized", + actor, + client, + principal, + crypto: cryptoService, + })); + } catch (e: any) { + auth.update(() => ({ + state: "error", + error: e.message || "An error occurred", + })); + } +} + +function handleSessionTimeout() { + setTimeout(() => { + try { + const delegation = JSON.parse( + window.localStorage.getItem("ic-delegation") ?? "null", + ) as { + delegations: Array<{ delegation: { expiration: string } }>; + } | null; + if (!delegation) return; + + const expirationTimeMs = + Number.parseInt(delegation.delegations[0].delegation.expiration, 16) / + 1000000; + + setTimeout(() => { + logout(); + }, expirationTimeMs - Date.now()); + } catch { + console.error("Could not handle delegation expiry."); + } + }); +} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/draft.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/draft.ts new file mode 100644 index 0000000000..7e89684d68 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/draft.ts @@ -0,0 +1,31 @@ +import { writable } from 'svelte/store'; +import { auth } from './auth'; + +interface DraftModel { + content: string; + tags: string[]; +} + +let initialDraft: DraftModel = { + content: '', + tags: [], +}; + +try { + const savedDraft: DraftModel = JSON.parse(localStorage.getItem('draft')); + if ('content' in savedDraft && 'tags' in savedDraft) { + initialDraft = savedDraft; + } +} catch {} + +export const draft = writable(initialDraft); + +draft.subscribe((draft) => { + localStorage.setItem('draft', JSON.stringify(draft)); +}); + +auth.subscribe(($auth) => { + if ($auth.state === 'anonymous') { + draft.set(initialDraft); + } +}); diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/notes.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/notes.ts new file mode 100644 index 0000000000..7b33c024da --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/notes.ts @@ -0,0 +1,133 @@ +import { writable } from 'svelte/store'; +import type { BackendActor } from '../lib/actor'; +import type { EncryptedNote } from '../bindings/backend'; +import type { CryptoService } from '../lib/crypto'; +import { deserialize, serialize } from '../lib/note'; +import type { NoteModel } from '../lib/note'; +import { auth } from './auth'; +import { showError } from './notifications'; + +export const notesStore = writable< + | { + state: 'uninitialized'; + } + | { + state: 'loading'; + } + | { + state: 'loaded'; + list: NoteModel[]; + } + | { + state: 'error'; + } +>({ state: 'uninitialized' }); + +let notePollerHandle: ReturnType | null; + +async function decryptNotes( + notes: EncryptedNote[], + cryptoService: CryptoService +): Promise { + // When notes are initially created, they do not have (and cannot have) any + // (encrypted) content yet because the note ID, which is needed to retrieve + // the note-specific encryption key, is not known yet before the note is + // created because the note ID is a return value of the call to create a note. + // The (encrypted) note content is stored in the backend only by a second call + // to the backend that updates the note's conent directly after the note is + // created. This means that there is a short period of time where the note + // already exists but doesn't have any (encrypted) content yet. + // To avoid decryption errors for these notes, we skip deserializing (and thus + // decrypting) these notes here. + const notes_with_content = notes.filter((note) => note.encryptedText != ""); + + return await Promise.all( + notes_with_content.map((encryptedNote) => deserialize(encryptedNote, cryptoService)) + ); +} + +function updateNotes(notes: NoteModel[]) { + notesStore.set({ + state: 'loaded', + list: notes, + }); +} + +export async function refreshNotes( + actor: BackendActor, + cryptoService: CryptoService +) { + const encryptedNotes = await actor.getNotes(); + + const notes = await decryptNotes(encryptedNotes, cryptoService); + updateNotes(notes); +} + +export async function addNote( + note: NoteModel, + actor: BackendActor, + crypto: CryptoService +) { + const new_id: bigint = await actor.createNote(); + note.id = new_id; + const encryptedNote = (await serialize(note, crypto)).encryptedText; + await actor.updateNote(new_id, encryptedNote); +} +export async function updateNote( + note: NoteModel, + actor: BackendActor, + crypto: CryptoService +) { + const encryptedNote = await serialize(note, crypto); + await actor.updateNote(note.id, encryptedNote.encryptedText); +} + +export async function addUser( + id: bigint, + user: string, + actor: BackendActor, +) { + await actor.addUser(id, user); +} + +export async function removeUser( + id: bigint, + user: string, + actor: BackendActor, +) { + await actor.removeUser(id, user); +} + +auth.subscribe(async ($auth) => { + if ($auth.state === 'initialized') { + if (notePollerHandle !== null) { + clearInterval(notePollerHandle); + notePollerHandle = null; + } + + notesStore.set({ + state: 'loading', + }); + try { + await refreshNotes($auth.actor, $auth.crypto).catch((e) => + showError(e, 'Could not poll notes.') + ); + + notePollerHandle = setInterval(async () => { + await refreshNotes($auth.actor, $auth.crypto).catch((e) => + showError(e, 'Could not poll notes.') + ); + }, 3000); + } catch { + notesStore.set({ + state: 'error', + }); + } + } else if ($auth.state === 'anonymous' && notePollerHandle !== null) { + clearInterval(notePollerHandle); + notePollerHandle = null; + notesStore.set({ + state: 'uninitialized', + }); + } +}); diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/notifications.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/notifications.ts new file mode 100644 index 0000000000..6b0aa0cad2 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/notifications.ts @@ -0,0 +1,30 @@ +import { writable } from 'svelte/store'; + +export interface Notification { + type: 'error' | 'info' | 'success'; + message: string; + id: number; +} + +export type NewNotification = Omit; + +let nextId = 0; + +export const notifications = writable([]); + +export function addNotification(notification: NewNotification, timeout = 2000) { + const id = nextId++; + + notifications.update(($n) => [...$n, { ...notification, id }]); + + setTimeout(() => { + notifications.update(($n) => $n.filter((n) => n.id != id)); + }, timeout); +} + +export function showError(e: any, message: string): never { + addNotification({ type: 'error', message }); + console.error(e); + console.error(e.stack); + throw e; +} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/svelte.config.js b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/svelte.config.js new file mode 100644 index 0000000000..8abe4369b8 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + preprocess: vitePreprocess(), +} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/tailwind.config.mjs b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/tailwind.config.mjs new file mode 100644 index 0000000000..6d6be576b4 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/tailwind.config.mjs @@ -0,0 +1,13 @@ +import daisyui from 'daisyui'; + +export default { + content: [ + './index.html', + './src/**/*.svelte', + './src/**/*.ts', + ], + theme: { + extend: {}, + }, + plugins: [daisyui], +}; diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/tsconfig.json b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/tsconfig.json new file mode 100644 index 0000000000..4365ffeb08 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "include": ["src/**/*", "index.html"], + "exclude": ["node_modules/*", "public/*", "src/declarations/**/*"] +} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/vite.config.ts b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/vite.config.ts new file mode 100644 index 0000000000..f3f735d144 --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/frontend/vite.config.ts @@ -0,0 +1,53 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { execSync } from "child_process"; +import { icpBindgen } from "@icp-sdk/bindgen/plugins/vite"; + +function getDevServerConfig() { + try { + const canisterId = execSync("icp canister status backend -e local -i", { + encoding: "utf-8", + stdio: "pipe", + }).trim(); + const networkStatus = JSON.parse( + execSync("icp network status --json", { + encoding: "utf-8", + stdio: "pipe", + }) + ); + return { + headers: { + "Set-Cookie": `ic_env=${encodeURIComponent( + `ic_root_key=${networkStatus.root_key}&PUBLIC_CANISTER_ID:backend=${canisterId}` + )}; SameSite=Lax;`, + }, + proxy: { + "/api": { target: "http://127.0.0.1:8000", changeOrigin: true }, + }, + }; + } catch {} + + throw new Error( + "No local network running. Start with:\n icp network start -d && icp deploy" + ); +} + +export default defineConfig(({ command }) => ({ + base: "./", + plugins: [ + svelte(), + icpBindgen({ + didFile: "../backend/backend.did", + outDir: "./src/bindings", + }), + ], + build: { + sourcemap: true, + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, + }, + server: command === "serve" ? getDevServerConfig() : undefined, +})); diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/motoko/icp.yaml b/motoko/vetkeys/encrypted_notes_app_vetkd/icp.yaml similarity index 50% rename from rust/vetkeys/encrypted_notes_app_vetkd/motoko/icp.yaml rename to motoko/vetkeys/encrypted_notes_app_vetkd/icp.yaml index ef936cb217..ba5979ac3f 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/motoko/icp.yaml +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/icp.yaml @@ -1,20 +1,19 @@ canisters: - - name: encrypted_notes + - name: backend recipe: type: "@dfinity/motoko@v5.0.0" init_args: type: text value: "(\"test_key_1\")" - - name: www + - name: frontend recipe: type: "@dfinity/asset-canister@v2.2.1" configuration: - dir: dist + dir: frontend/dist build: - - npm install --include=dev --legacy-peer-deps --prefix ../frontend - - npm run build --prefix ../frontend - - rm -rf dist && mv ../frontend/dist dist && cp dist/index.html dist/404.html + - npm install --prefix frontend + - npm run build --prefix frontend networks: - name: local diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/mops.toml b/motoko/vetkeys/encrypted_notes_app_vetkd/mops.toml new file mode 100644 index 0000000000..12ae91caaa --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/mops.toml @@ -0,0 +1,15 @@ +[toolchain] +moc = "1.11.0" + +[dependencies] +core = "2.5.0" + +[moc] +# M0236: use context dot notation +# M0237: redundant explicit implicit arguments +# M0223: redundant type instantiation +args = ["--default-persistent-actors", "-W=M0236,M0237,M0223"] + +[canisters.backend] +main = "backend/app.mo" +candid = "backend/backend.did" diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/package.json b/motoko/vetkeys/encrypted_notes_app_vetkd/package.json new file mode 100644 index 0000000000..caa53e71ff --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/package.json @@ -0,0 +1,13 @@ +{ + "name": "encrypted_notes_app_vetkd", + "private": true, + "type": "module", + "scripts": { + "build": "npm run build --workspaces --if-present", + "prebuild": "npm run prebuild --workspaces --if-present", + "dev": "npm run dev --workspaces --if-present" + }, + "workspaces": [ + "frontend" + ] +} diff --git a/motoko/vetkeys/encrypted_notes_app_vetkd/security-checklist.md b/motoko/vetkeys/encrypted_notes_app_vetkd/security-checklist.md new file mode 100644 index 0000000000..462d6686fa --- /dev/null +++ b/motoko/vetkeys/encrypted_notes_app_vetkd/security-checklist.md @@ -0,0 +1,40 @@ +# Security checklist (Motoko) + +These notes summarize how this example's **Motoko** backend addresses the security considerations most relevant to a vetKeys app. They follow the broader [IC security best practices](https://docs.internetcomputer.org/guides/security/overview/), which are more exhaustive — a production app should still perform its own review. + +Checked items (`[x]`) are implemented by this example. Unchecked items (`[ ]`) are recommendations that this example intentionally leaves out for simplicity and that a production app should still address. + +## Authentication + +- [x] Every note operation requires authentication and rejects the anonymous principal (`assert not Principal.isAnonymous(caller)`). + +## Consensus + +- [x] The public API has no `query` methods; `getNotes` is an update call, so results go through consensus and cannot be forged by a single malicious node. + +## Input validation + +- [x] Public methods validate their arguments (per-user note limits, note-size limits, authorization checks) and trap on invalid input. + +## End-to-end encryption (frontend) + +- [x] Notes are encrypted in the browser; the canister only ever stores ciphertext. +- [x] Encryption uses a fresh random IV per message (no deterministic encryption). +- [x] Derived keys are stored as non-extractable `CryptoKey`s in IndexedDB. +- [ ] Shorten the Internet Identity delegation lifetime for a security-sensitive app. +- [ ] Rotate encryption keys periodically. + +## vetKD and inter-canister calls + +- [x] The backend calls only the trusted vetKD **management canister** (`aaaaa-aa`) to derive keys, and does not mutate shared state after an `await`, avoiding reentrancy hazards. + +## Canister storage and upgrades + +- [x] Only encrypted data is stored on the canister. +- [x] Per-user limits bound how much any caller can store (max notes, note size, shares). +- [x] State is retained across upgrades. +- [ ] For long-term upgradeability, back large state with stable memory directly and version it. + +## Asset certification + +- [x] The frontend is served by the asset canister with certified HTTP responses. diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/rust/Cargo.toml b/rust/vetkeys/encrypted_notes_app_vetkd/Cargo.toml similarity index 100% rename from rust/vetkeys/encrypted_notes_app_vetkd/rust/Cargo.toml rename to rust/vetkeys/encrypted_notes_app_vetkd/Cargo.toml diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/README.md b/rust/vetkeys/encrypted_notes_app_vetkd/README.md index 0effa17d9d..ee481289df 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/README.md +++ b/rust/vetkeys/encrypted_notes_app_vetkd/README.md @@ -1,82 +1,77 @@ -# Encrypted notes: vetKD +# Encrypted Notes: vetKD (Rust) - +[View this sample's code on GitHub](https://github.com/dfinity/examples/tree/master/rust/vetkeys/encrypted_notes_app_vetkd) -Encrypted notes is an example dapp 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 dapp's frontend. +Also available in: [Motoko](../../../motoko/vetkeys/encrypted_notes_app_vetkd) -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 dapp, plus sharing of notes becomes possible. +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. + +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. 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. -## Prerequisites +## Build and deploy from the command line -- [x] Install the [ICP CLI](https://cli.internetcomputer.org). -- [x] Install [npm](https://www.npmjs.com/package/npm). +### Prerequisites -## Folder Structure +- Install [Node.js](https://nodejs.org/en/download/) +- Install [icp-cli](https://cli.internetcomputer.org): `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm` +- Install the [Rust toolchain](https://www.rust-lang.org/tools/install), then add the WASM target: `rustup target add wasm32-unknown-unknown` -This example provides both a **Rust** and a **Motoko** backend, sharing a common `frontend/`: +### Install -``` -encrypted_notes_app_vetkd/ -├── frontend/ ← shared frontend (symlinked into rust/ and motoko/) -├── motoko/ ← Motoko backend + icp.yaml -└── rust/ ← Rust backend + icp.yaml +```bash +git clone https://github.com/dfinity/examples +cd examples/rust/vetkeys/encrypted_notes_app_vetkd ``` -## Deploy the Canisters Locally +### Deploy -Deploy with the **Motoko** backend: ```bash -cd motoko -icp network start -d && icp deploy +icp network start -d +icp deploy ``` -Or deploy with the **Rust** backend: +Open the frontend URL printed by `icp deploy`. + +To run the frontend in development mode with hot reloading (after `icp deploy`): + ```bash -cd rust -icp network start -d && icp deploy +npm run dev ``` -## Running the Frontend in Development Mode +When done, stop the local network to free up the port for other projects: -After deploying, run from the `frontend` folder: ```bash -cd frontend -npm run dev:motoko # if you deployed the Motoko backend -# or -npm run dev:rust # if you deployed the Rust backend +icp network stop ``` -## Example Components +## Example components -### Backend +### Backend (`backend/`) -The backend consists of a canister that stores encrypted notes. It is automatically deployed with `icp deploy`. +A single Rust canister that stores encrypted notes. It is deployed automatically with `icp deploy`. -### Frontend +### Frontend (`frontend/`) -The frontend is a **Svelte** application providing a user-friendly interface for managing encrypted notes. +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. ## Limitations -This example dapp does not implement key rotation, which is strongly recommended in a production environment. +This example app does not implement key rotation, which is strongly recommended in a production environment. ## Troubleshooting 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. -## API Level +## API level -This example intentionally uses the **raw VetKD management canister API** (`encrypted_symmetric_key_for_note`, `symmetric_key_verification_key_for_note`) to demonstrate how VetKD works at the protocol level. +This example intentionally uses the **raw vetKD management canister API** (`encrypted_symmetric_key_for_note`, `symmetric_key_verification_key_for_note`) to demonstrate how vetKD works at the protocol level. -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/) and [**VetKD Password Manager with Metadata**](../password_manager_with_metadata/) examples for how `EncryptedMaps` is used in practice. +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. -## Additional Resources +## Additional resources -- **[What are VetKeys](https://docs.internetcomputer.org/concepts/vetkeys)** - For more information about VetKeys and VetKD. +- **[What are VetKeys](https://docs.internetcomputer.org/concepts/vetkeys)** — more information about VetKeys and VetKD. +- [Security checklist for this example](security-checklist.md) +- [Security best practices](https://docs.internetcomputer.org/guides/security/overview/) diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/rust/backend/Cargo.toml b/rust/vetkeys/encrypted_notes_app_vetkd/backend/Cargo.toml similarity index 92% rename from rust/vetkeys/encrypted_notes_app_vetkd/rust/backend/Cargo.toml rename to rust/vetkeys/encrypted_notes_app_vetkd/backend/Cargo.toml index 48c9a0a7b1..5d071eb1fd 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/rust/backend/Cargo.toml +++ b/rust/vetkeys/encrypted_notes_app_vetkd/backend/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "encrypted_notes_backend" +name = "backend" version = "0.1.0" edition = "2018" diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/rust/backend/src/encrypted_notes_rust.did b/rust/vetkeys/encrypted_notes_app_vetkd/backend/backend.did similarity index 100% rename from rust/vetkeys/encrypted_notes_app_vetkd/rust/backend/src/encrypted_notes_rust.did rename to rust/vetkeys/encrypted_notes_app_vetkd/backend/backend.did diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/rust/backend/src/lib.rs b/rust/vetkeys/encrypted_notes_app_vetkd/backend/src/lib.rs similarity index 96% rename from rust/vetkeys/encrypted_notes_app_vetkd/rust/backend/src/lib.rs rename to rust/vetkeys/encrypted_notes_app_vetkd/backend/src/lib.rs index 3bd0f2538a..ac73cdca53 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/rust/backend/src/lib.rs +++ b/rust/vetkeys/encrypted_notes_app_vetkd/backend/src/lib.rs @@ -63,8 +63,8 @@ impl Storable for NoteIds { // We use a canister's stable memory as storage. This simplifies the code and makes the appliation // more robust because no (potentially failing) pre_upgrade/post_upgrade hooks are needed. // Note that stable memory is less performant than heap memory, however. -// Currently, a single canister smart contract is limited to 96 GB of stable memory. -// For the current limits see https://internetcomputer.org/docs/current/developer-docs/production/resource-limits. +// Currently, a single canister is limited to 96 GB of stable memory. +// For the current limits see https://docs.internetcomputer.org/references/resource-limits. // To ensure that our canister does not exceed the limit, we put various restrictions (e.g., number of users) in place. static MAX_USERS: u64 = 1_000; static MAX_NOTES_PER_USER: usize = 500; @@ -138,12 +138,12 @@ fn caller() -> Principal { // #[query(name = "notesCnt")] ... // // 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 +// Thus, we avoid using queries throughout this app, ensuring that the result of our // methods gets through consensus. Otherwise, this method could e.g. omit some notes -// if it got executed by a malicious node. (To make the dapp more efficient, one could +// if it got executed by a malicious node. (To make the app 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 +// See https://docs.internetcomputer.org/guides/canister-calls/calling-from-clients/#query-vs-update-calls /// Reflects the [caller]'s identity by returning (a future of) its principal. /// Useful for debugging. diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/.gitignore b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/.gitignore new file mode 100644 index 0000000000..061e2e66e1 --- /dev/null +++ b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +src/bindings/ diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/package.json b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/package.json index 96ba0925a1..a47e8cdc67 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/package.json +++ b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/package.json @@ -1,39 +1,37 @@ { - "name": "encrypted-notes-frontend", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "printf '\\nNo backend specified. Use one of:\\n\\n npm run dev:motoko\\n npm run dev:rust\\n\\n' && exit 1", - "dev:motoko": "npm run build:bindings && BACKEND=motoko vite", - "dev:rust": "npm run build:bindings && BACKEND=rust vite", - "build": "npm run build:bindings && vite build", - "build:bindings": "cd scripts && ./gen_bindings.sh", - "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.json" - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.2", - "@tsconfig/svelte": "^5.0.4", - "@types/node": "^24.0.10", - "autoprefixer": "^10.4.2", - "postcss": "^8.4.31", - "svelte": "^4.2.19", - "svelte-check": "^3.8.6", - "tailwindcss": "^3.0.17", - "tslib": "^2.8.1", - "typescript": "~5.7.2", - "vite": "^5.4.21" - }, - "dependencies": { - "@icp-sdk/auth": "^7.1.0", - "@icp-sdk/core": "^5.4.0", - "@icp-sdk/vetkeys": "^0.5.0-beta.0", - "daisyui": "^1.25.4", - "idb-keyval": "6.2.1", - "isomorphic-dompurify": "^2.25.0", - "svelte-icons": "^2.1.0", - "svelte-spa-router": "^4.0.1", - "typewriter-editor": "^0.6.45" - } -} \ No newline at end of file + "name": "frontend", + "private": true, + "type": "module", + "scripts": { + "prebuild": "npm i --include=dev", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@icp-sdk/bindgen": "~0.2.2", + "@sveltejs/vite-plugin-svelte": "^3.0.2", + "@tsconfig/svelte": "^5.0.4", + "@types/node": "^24.0.10", + "autoprefixer": "^10.4.2", + "postcss": "^8.4.31", + "svelte": "^4.2.19", + "svelte-check": "^3.8.6", + "tailwindcss": "^3.0.17", + "tslib": "^2.8.1", + "typescript": "~5.7.2", + "vite": "^5.4.21" + }, + "dependencies": { + "@icp-sdk/auth": "^7.1.0", + "@icp-sdk/core": "^5.4.0", + "@icp-sdk/vetkeys": "^0.5.0-beta.0", + "daisyui": "^1.25.4", + "idb-keyval": "6.2.1", + "isomorphic-dompurify": "^2.25.0", + "svelte-icons": "^2.1.0", + "svelte-spa-router": "^4.0.1", + "typewriter-editor": "^0.6.45" + } +} diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/scripts/gen_bindings.sh b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/scripts/gen_bindings.sh deleted file mode 100755 index 895b957abb..0000000000 --- a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/scripts/gen_bindings.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -set -eu - -# Resolve the script's physical location so we work correctly even when the -# icp CLI has symlinked `frontend/` into a backend subdirectory for the build. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -FRONTEND_DIR="$(dirname "$SCRIPT_DIR")" -EXAMPLE_ROOT="$(dirname "$FRONTEND_DIR")" - -# Bindings are always generated from the Rust backend since both backends -# expose the same Candid interface. - -rm -rf "$FRONTEND_DIR/src/declarations/encrypted_notes" -mkdir -p "$FRONTEND_DIR/src/declarations/encrypted_notes" -npx --yes @icp-sdk/bindgen \ - --did-file "$EXAMPLE_ROOT/rust/backend/src/encrypted_notes_rust.did" \ - --out-dir "$FRONTEND_DIR/src/declarations/encrypted_notes" \ - --declarations-flat --force diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/actor.ts b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/actor.ts index b29294c5c6..d953820035 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/actor.ts +++ b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/actor.ts @@ -1,15 +1,15 @@ import { HttpAgent } from "@icp-sdk/core/agent"; import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; -import { createActor as createEncryptedNotesActor, type Backend } from "../declarations/encrypted_notes/encrypted_notes_rust"; +import { createActor as createEncryptedNotesActor, type Backend } from "../bindings/backend"; export type BackendActor = Backend; const canisterEnv = safeGetCanisterEnv<{ - "PUBLIC_CANISTER_ID:encrypted_notes": string; + "PUBLIC_CANISTER_ID:backend": string; }>(); export async function createActor(options?: { identity?: any }): Promise { - const canisterId = canisterEnv?.["PUBLIC_CANISTER_ID:encrypted_notes"]; + const canisterId = canisterEnv?.["PUBLIC_CANISTER_ID:backend"]; const agent = await HttpAgent.create({ identity: options?.identity, host: window.location.origin, diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/note.ts b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/note.ts index 10d6598c43..9cb2b21058 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/note.ts +++ b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/lib/note.ts @@ -1,4 +1,4 @@ -import type { EncryptedNote } from '../declarations/encrypted_notes/backend.did'; +import type { EncryptedNote } from '../bindings/backend'; import type { CryptoService } from './crypto'; import type { Principal } from '@icp-sdk/core/principal'; diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/notes.ts b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/notes.ts index cdf9dd871c..39ed61d2c6 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/notes.ts +++ b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/src/store/notes.ts @@ -1,6 +1,6 @@ import { writable } from 'svelte/store'; import type { BackendActor } from '../lib/actor'; -import type { EncryptedNote } from '../lib/backend'; +import type { EncryptedNote } from '../bindings/backend'; import type { CryptoService } from '../lib/crypto'; import { deserialize, serialize } from '../lib/note'; import type { NoteModel } from '../lib/note'; diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/vite.config.ts b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/vite.config.ts index 3d6221a968..f3f735d144 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/vite.config.ts +++ b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/vite.config.ts @@ -1,36 +1,46 @@ import { defineConfig } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; import { execSync } from "child_process"; - -const environment = process.env.ICP_ENVIRONMENT || "local"; -const CANISTER_NAMES = ["encrypted_notes"]; +import { icpBindgen } from "@icp-sdk/bindgen/plugins/vite"; function getDevServerConfig() { - const backend = process.env.BACKEND; - if (!backend) { - throw new Error("BACKEND env var is required. Use `npm run dev:motoko` or `npm run dev:rust`."); - } - const networkStatus = JSON.parse( - execSync(`icp network status -e ${environment} --json --project-root-override ../${backend}`, { encoding: "utf-8" }), + try { + const canisterId = execSync("icp canister status backend -e local -i", { + encoding: "utf-8", + stdio: "pipe", + }).trim(); + const networkStatus = JSON.parse( + execSync("icp network status --json", { + encoding: "utf-8", + stdio: "pipe", + }) + ); + return { + headers: { + "Set-Cookie": `ic_env=${encodeURIComponent( + `ic_root_key=${networkStatus.root_key}&PUBLIC_CANISTER_ID:backend=${canisterId}` + )}; SameSite=Lax;`, + }, + proxy: { + "/api": { target: "http://127.0.0.1:8000", changeOrigin: true }, + }, + }; + } catch {} + + throw new Error( + "No local network running. Start with:\n icp network start -d && icp deploy" ); - const canisterParams = CANISTER_NAMES.map((name) => { - const id = execSync( - `icp canister status ${name} -e ${environment} --id-only --project-root-override ../${backend}`, - { encoding: "utf-8", stdio: "pipe" }, - ).trim(); - return `PUBLIC_CANISTER_ID:${name}=${id}`; - }).join("&"); - return { - headers: { - "Set-Cookie": `ic_env=${encodeURIComponent(`${canisterParams}&ic_root_key=${networkStatus.root_key}`)}; SameSite=Lax;`, - }, - proxy: { "/api": { target: networkStatus.api_url, changeOrigin: true } }, - hmr: false, - }; } export default defineConfig(({ command }) => ({ - plugins: [svelte()], + base: "./", + plugins: [ + svelte(), + icpBindgen({ + didFile: "../backend/backend.did", + outDir: "./src/bindings", + }), + ], build: { sourcemap: true, rollupOptions: { @@ -39,6 +49,5 @@ export default defineConfig(({ command }) => ({ }, }, }, - root: "./", - ...(command === "serve" ? { server: getDevServerConfig() } : {}), + server: command === "serve" ? getDevServerConfig() : undefined, })); diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/icp.yaml b/rust/vetkeys/encrypted_notes_app_vetkd/icp.yaml new file mode 100644 index 0000000000..3b7759d854 --- /dev/null +++ b/rust/vetkeys/encrypted_notes_app_vetkd/icp.yaml @@ -0,0 +1,23 @@ +canisters: + - name: backend + recipe: + type: "@dfinity/rust@v3.3.0" + configuration: + candid: backend/backend.did + init_args: + type: text + value: "(\"test_key_1\")" + + - name: frontend + recipe: + type: "@dfinity/asset-canister@v2.2.1" + configuration: + dir: frontend/dist + build: + - npm install --prefix frontend + - npm run build --prefix frontend + +networks: + - name: local + mode: managed + ii: true diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/motoko/frontend b/rust/vetkeys/encrypted_notes_app_vetkd/motoko/frontend deleted file mode 120000 index af288785f3..0000000000 --- a/rust/vetkeys/encrypted_notes_app_vetkd/motoko/frontend +++ /dev/null @@ -1 +0,0 @@ -../frontend \ No newline at end of file diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/motoko/mops.toml b/rust/vetkeys/encrypted_notes_app_vetkd/motoko/mops.toml deleted file mode 100644 index 99f601ef11..0000000000 --- a/rust/vetkeys/encrypted_notes_app_vetkd/motoko/mops.toml +++ /dev/null @@ -1,8 +0,0 @@ -[toolchain] -moc = "1.9.0" - -[dependencies] -core = "2.5.0" - -[canisters.encrypted_notes] -main = "backend/main.mo" diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/package.json b/rust/vetkeys/encrypted_notes_app_vetkd/package.json new file mode 100644 index 0000000000..caa53e71ff --- /dev/null +++ b/rust/vetkeys/encrypted_notes_app_vetkd/package.json @@ -0,0 +1,13 @@ +{ + "name": "encrypted_notes_app_vetkd", + "private": true, + "type": "module", + "scripts": { + "build": "npm run build --workspaces --if-present", + "prebuild": "npm run prebuild --workspaces --if-present", + "dev": "npm run dev --workspaces --if-present" + }, + "workspaces": [ + "frontend" + ] +} diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/rust/rust-toolchain.toml b/rust/vetkeys/encrypted_notes_app_vetkd/rust-toolchain.toml similarity index 100% rename from rust/vetkeys/encrypted_notes_app_vetkd/rust/rust-toolchain.toml rename to rust/vetkeys/encrypted_notes_app_vetkd/rust-toolchain.toml diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/rust/frontend b/rust/vetkeys/encrypted_notes_app_vetkd/rust/frontend deleted file mode 120000 index af288785f3..0000000000 --- a/rust/vetkeys/encrypted_notes_app_vetkd/rust/frontend +++ /dev/null @@ -1 +0,0 @@ -../frontend \ No newline at end of file diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/rust/icp.yaml b/rust/vetkeys/encrypted_notes_app_vetkd/rust/icp.yaml deleted file mode 100644 index bd77e42f5e..0000000000 --- a/rust/vetkeys/encrypted_notes_app_vetkd/rust/icp.yaml +++ /dev/null @@ -1,25 +0,0 @@ -canisters: - - name: encrypted_notes - recipe: - type: "@dfinity/rust@v3.2.0" - configuration: - package: encrypted_notes_backend - candid: backend/src/encrypted_notes_rust.did - init_args: - type: text - value: "(\"test_key_1\")" - - - name: www - recipe: - type: "@dfinity/asset-canister@v2.2.1" - configuration: - dir: dist - build: - - npm install --include=dev --legacy-peer-deps --prefix ../frontend - - npm run build --prefix ../frontend - - rm -rf dist && mv ../frontend/dist dist - -networks: - - name: local - mode: managed - ii: true diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/security-checklist.md b/rust/vetkeys/encrypted_notes_app_vetkd/security-checklist.md index 7f2c2a9a58..77cf464918 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/security-checklist.md +++ b/rust/vetkeys/encrypted_notes_app_vetkd/security-checklist.md @@ -1,165 +1,44 @@ -This document lists typical steps of a security review needed for production-ready IC dapps. We indicate whether the two backend implementations of Encrypted Notes comply with the corresponding requirements (marked as Done), do not yet comply (Future), or whether a particular requirement is not applicable to this backend (Not applicable). +# Security checklist (Rust) -While this list might help creating better IC dapps, keep in mind that the list is potentially incomplete. In particular, each real-world dapp may have a different set of security requirements that depend on its target domain and intended use case. +These notes summarize how this example's **Rust** backend addresses the security considerations most relevant to a vetKeys app. They follow the broader [IC security best practices](https://docs.internetcomputer.org/guides/security/overview/), which are more exhaustive — a production app should still perform its own review. -# 1. Authentication +Checked items (`[x]`) are implemented by this example. Unchecked items (`[ ]`) are recommendations that this example intentionally leaves out for simplicity and that a production app should still address. -### 1.1. Make sure any action that only a specific user should be able to do requires authentication -* Motoko: Done -* Rust: Done +## Authentication -### 1.2. Disallow the anonymous principal in authenticated calls -* Motoko: Done -* Rust: Done +- [x] Every note operation requires authentication and rejects the anonymous principal (the `caller()` helper traps for the anonymous principal). -# 2. Consensus +## Consensus -Avoid using uncertified queries in public canister APIs. Instead, either use certified update methods or design an eventual certification approach for performance-critical dapps. -* Motoko: Done (no query methods) -* Rust: Done (no query methods) +- [x] The public API has no `query` methods; `get_notes` is an update call, so results go through consensus and cannot be forged by a single malicious node. -# 3. Input Validation +## Input validation -Each public API method should sanitize their arguments and gracefully handle exceptional situations. -* Motoko: Done -* Rust: Done +- [x] Public methods validate their arguments (per-user note limits, note-size limits, authorization checks) and trap on invalid input. -# 4. Frontend security +## End-to-end encryption (frontend) -### 4.1. Frontend input validation -* Motoko: Done -* Rust: Done +- [x] Notes are encrypted in the browser; the canister only ever stores ciphertext. +- [x] Encryption uses a fresh random IV per message (no deterministic encryption). +- [x] Derived keys are stored as non-extractable `CryptoKey`s in IndexedDB. +- [ ] Shorten the Internet Identity delegation lifetime for a security-sensitive app. +- [ ] Rotate encryption keys periodically. -### 4.2. Avoid using deterministic encryption. -For example, the initialization vector for AES-GCM encryption should be unique for each message (or chosen at random). -* Motoko: Done -* Rust: Done +## vetKD and inter-canister calls -### 4.3. Do not load untrusted assets like CSS or fonts -* Motoko: Done -* Rust: Done +- [x] The backend calls only the trusted vetKD **management canister** (`aaaaa-aa`) to derive keys, and does not mutate shared state after an `await`, avoiding reentrancy hazards. -### 4.4. Avoid logging sensitive data like private keys -When generating the private key using `crypto.subtle.generateKey`, set `extractable=false`. Consider offloading the secret keys to a YubiKey or YubyHSM so that the secret keys never end up in the browser. -* Motoko: Done -* Rust: Done +## Canister storage and upgrades -### 4.5. Avoid reusing the same public/private key pair for every identity in the same browser -* Motoko: Future -* Rust: Future +- [x] State is stored directly in stable memory via `ic-stable-structures` (`StableBTreeMap`), so it is upgrade-safe by construction (no serialization step that could time out on large data). +- [x] Only encrypted data is stored on the canister. +- [x] Per-user limits bound how much any caller can store (max notes, note size, shares). -### 4.6. Set reasonable session timeouts -For example, a security-sensitive dapp like Encrypted Notes should set `maxTimeToLive` for Internet Identity delegation to 30 min rather than 24 h. -* Motoko: Future -* Rust: Future +## Rust-specific -### 4.7. Regularly refresh symmetric encryption keys -* Motoko: Future -* Rust: Future +- [x] No `unsafe` code. +- [x] Arithmetic is written to avoid integer overflow (e.g. the note-ID counter uses `checked_add`). -# 5. Asset Certification +## Asset certification -### 5.1. Use HTTP asset certification and avoid serving your dapp through raw.ic0.app -* Motoko: Done -* Rust: Done - -# 6. Canister Storage - -### 6.1. Use thread_local! with Cell/RefCell for state variables and put all your globals in one basket -* Motoko: Not applicable -* Rust: Done - -### 6.2. Limit the amount of data that can be stored in a canister per user -* Motoko: Done -* Rust: Done - -### 6.3. Consider using stable memory, version it, test it -* Motoko: Done (except versioning) -* Rust: Done (except versioning) - -### 6.4. Don’t store sensitive data on canisters (unless it is encrypted) -* Motoko: Done -* Rust: Done - -### 6.5. Create backups -* Motoko: Future -* Rust: Future - -# 7. Inter-Canister Calls and Rollbacks - -### 5.1. Don’t panic after await and don’t lock shared resources across await boundaries -* Motoko: Done (we don't use await) -* Rust: Done (we don't use await) - -### 5.2. Be aware that state may change during inter-canister calls -* Motoko: Done (we have no inter-canister calls) -* Rust: Done (we have no inter-canister calls) - -### 5.3. Only make inter-canister calls to trustworthy canisters -* Motoko: Done (we have no inter-canister calls) -* Rust: Done (we have no inter-canister calls) - -### 5.4. Make sure there are no loops in call graphs -* Motoko: Done -* Rust: Done - -# 8. Canister Upgrades - -### 8.1. Don’t panic/trap during upgrades: -* Motoko: Done, assuming that [`Iter.toArray`](https://github.com/dfinity/motoko-base/blob/master/src/Iter.mo) and [`Map.fromIter`](https://github.com/dfinity/motoko-base/blob/master/src/HashMap.mo) do not trap. -* Rust: Done, assuming that [`borrow_mut`](https://doc.rust-lang.org/std/borrow/trait.BorrowMut.html#tymethod.borrow_mut), [`std::mem::take`](https://doc.rust-lang.org/stable/std/mem/fn.take.html), and [`ic_cdk::storage::stable_save`](https://docs.rs/ic-cdk/latest/ic_cdk/storage/fn.stable_save.html) do not panic. - -### 8.2. Ensure upgradeability -If the canister storage becomes too big, the canister will no longer be upgradable because `pre_upgrade` will time out or the canister will run out of cycles. The recommended remedy is to use stable memory directly rather than serializing data upon upgrade. -* Motoko: Future -* Rust: Future - -# 9. Rust-specific issues - -### 9.1. Don’t use unsafe Rust code: -* Rust: Done - -### 9.2. Avoid integer overflows: -* Rust: Done - -# 10. Miscellaneous - -### 10.1. For expensive calls, consider using captchas or proof of work -* Motoko: Future -* Rust: Future - -### 10.2. Test your canister code even in presence of System API calls -* Motoko: Future -* Rust: Future - -### 10.3. Make canister builds reproducible -* Motoko: Done (via Docker) -* Rust: Done (via Docker) - -### 10.4. Expose metrics from your canister -* Motoko: Future -* Rust: Future - -### 10.5. Don’t rely on time being strictly monotonic -* Motoko: Done -* Rust: Done - -### 10.6. Protect against draining the cycles balance -* Motoko: Future -* Rust: Future - - -# 11. Efficiency considerations - -### 11.1. `submit_ciphertexts` -* Adding submit_ciphertexts is currently O(C*D) where `C = ciphertexts.size()` and `D = store.device_list.size()` - -# 12. Usability - -### 12.1. Confirm user's intention before executing potentially irreversible actions like device removal -* Motoko: Future -* Rust: Future - -### 12.2. Prevent account lockout scenarios -* Motoko: Future -* Rust: Future +- [x] The frontend is served by the asset canister with certified HTTP responses.