diff --git a/README.md b/README.md old mode 100755 new mode 100644 index bbabe07..5fff290 --- a/README.md +++ b/README.md @@ -1,291 +1,81 @@ -# B3Note +
- B3Note + B3Note -**Private notes on the Internet Computer.** Notes are encrypted in your browser, so the canister only ever -stores ciphertext. With Internet Identity the key is derived from your identity by the IC's -[vetKeys](https://internetcomputer.org/docs/building-apps/network-features/vetkeys/introduction); as a -guest it is derived from your guest key. Internet Identity users can also share a note through a link -that **burns after reading**. +### Private notes that only you can read. -## Features - -- **End-to-end encrypted notes.** Markdown with checklists, tags, pinning, search, autosave and - conflict detection across devices. -- **Self-destructing notes.** Give a note a timer and the canister deletes it when it runs out. -- **Burn-after-reading links** (Internet Identity accounts). Share a snapshot that can be opened once - (or up to N times) before it expires. After the last view the ciphertext is deleted. Nobody can decrypt - it again, even someone who holds the link and an old copy of the ciphertext, because only the canister - can request the share's key, and it does so once per view. Readers do not need to sign in. -- **Sign in your way.** Use Internet Identity, or a *guest key*: a private key kept in your browser, with a - recovery key you can save and import on another device. -- **On-device AI assistant**: summarize, suggest a title or tags, improve the writing, fix grammar, - shorten, pull out action items, translate, continue writing, and ask questions across your notes, with - a model that runs in your browser, so nothing leaves the end-to-end encryption. It uses Chrome's - built-in Gemini Nano when the browser has it, and otherwise downloads an open model - (Qwen2.5-1.5B-Instruct, about 1 GB, once, after you ask) and runs it on the GPU with - [WebLLM](https://github.com/mlc-ai/web-llm), when the GPU supports 16-bit floats (WebGPU's - `shader-f16`). Browsers with neither show no on-device AI. -- **Optional AI on the Internet Computer** through the IC's [LLM canister](https://github.com/dfinity/llm), - **off by default**. Text sent to it leaves the end-to-end encryption, so a controller has to switch it - on (`ai_enabled`); when on, it is a second choice in the assistant, nothing is sent until you choose an - action, and the app asks for consent first. -- **Your data stays yours.** Export to JSON or Markdown, import Markdown files, and delete your account. - -## How the encryption works - -``` - browser backend canister vetKD (IC) - ┌──────────────────────────────┐ get_encrypted_user_key(tpk) ┌──────────────┐ vetkd_derive_key - │ transport key pair (tsk, tpk)│ ─────────────────────────────▶ │ input = │ ───────────────────▶ - │ │ ◀───────────────────────────── │ caller │ ◀─── key encrypted - │ decrypt + verify the vetKey │ encrypted vetKey + public key│ principal │ to tpk - │ → AES-GCM key (non-extract.) │ └──────────────┘ - │ encrypt(note, AD = note id) │ ── create_note(ciphertext) ──▶ stores ciphertext only - └──────────────────────────────┘ -``` - -- **Notes (Internet Identity).** Each user's vetKey is derived from their principal (context - `b3note/user-key/v1`). Only that principal can request it, and it reaches the browser encrypted to a - one-time transport key. The browser verifies it, turns it into a non-extractable AES-GCM key (cached in - IndexedDB) and encrypts each note with the note id as associated data. A server that swaps ciphertexts - between notes therefore gets a decryption error, not the wrong note. -- **Notes (guests).** A guest key is already a secret only its holder has, so the browser derives the - note key from it (HKDF-SHA-256, salt `b3note/guest-key/v1`, info = the canister id) and encrypts the - same way. It costs the canister nothing, and the recovery key restores it on any device. -- **Who gets vetKD.** Every derivation costs the canister cycles, so `get_encrypted_user_key` and - `create_share` are for Internet Identity accounts only. The client sends the root public key of its - delegation chain; the canister checks that it hashes to the caller's principal and that it is a - canister-signature key of Internet Identity (`rdmx6-jaaaa-aaaaa-aaadq-cai`). Opening a share needs no - account, and is bounded by the share's views and the global hourly budget. -- **Key pinning.** On mainnet the app derives the canister's vetKD public keys from the IC's published - master key and refuses keys that do not match. -- **Share links.** The browser IBE-encrypts a snapshot to the identity *share id* under the canister's - share key (context `b3note/share/v1`). It also makes a fresh Ed25519 key, uploads the public half, and - puts the secret in the link's `#fragment`, which browsers never send to a server. To open, the reader - signs `(domain, share id, transport key)`. The canister checks the signature, spends a view, and has the - share key derived and encrypted to the reader. A transport key can be used once per share, so a - replayed request cannot burn another view. -- **On-device AI.** The model reads the note in the browser that decrypted it. The only network traffic - is the model's files on first use (Hugging Face and the MLC model library on GitHub), which is why the - page's CSP allows those hosts and `'wasm-unsafe-eval'` for the model's WebAssembly. Both are pinned to a - commit, and WebLLM checks the model's config, its tokenizer and its WebAssembly library against hashes - before it runs them (`frontend/src/lib/localAi.ts`), so a push to either repository cannot change the - code that reads your notes. -- **No remote images.** Fetching an image sends its URL to its host, so a note's Markdown or an AI answer - (which a prompt hidden in a note could steer) could carry text out that way, and an image in a shared - note would tell its sender when it was opened. Markdown shows images as links instead, and the CSP's - `img-src` allows only the app itself. -- **AI on the canister (when switched on).** The assistant needs plaintext. Only the text you pick for an action (or, - for *Ask your notes*, the few notes your browser ranks as relevant) goes to the LLM canister. - Everything else stays encrypted. - -What the canister does see: note sizes, timestamps, versions and expiry times, the number of notes, and -which principal owns them. - -## Repository layout - -``` -backend/ Rust canister (ic-cdk 0.20, ic-stable-structures 0.7, vetKD, ic-llm) - backend.did Candid interface (checked by a unit test, see below) -mock_llm/ Test double of the LLM canister's v1_chat endpoint -integration_tests/ PocketIC tests with real vetKD (keys verified with the ic-vetkeys crate) -frontend/ Vite + React 19 + Tailwind v4 app on ic-reactor 4 (beta) and @icp-sdk/* - src/canisters/ Generated from backend.did by @ic-reactor/vite-plugin (candid-core) - src/reactor.ts The ic-reactor client, its sign-in, and the backend handle - src/lib/ Crypto (vault, share links, key pinning), note format, search, sign-in - tests/ Test-client tests and the PicJS suite against the real canister -icp.yaml icp-cli project: backend, frontend, local network with Internet Identity -scripts/ build-canisters.sh, test-backend.sh -``` - -## Getting started - -Prerequisites: Rust with the `wasm32-unknown-unknown` target, Node.js 22 with pnpm, and -[icp-cli](https://cli.internetcomputer.org) with `ic-wasm` -(`npm i -g @icp-sdk/icp-cli @icp-sdk/ic-wasm`). - -```bash -rustup target add wasm32-unknown-unknown -icp network start -d # a local network with Internet Identity and the vetKD test keys -icp deploy # builds and deploys backend + frontend -``` - -Open the `frontend` URL that `icp deploy` prints. For hot reload, run `pnpm dev` in `frontend/`. The -ic-reactor Vite plugin reads canister ids and the root key from the local network. - -**Without icp-cli** (UI work only): `./scripts/build-canisters.sh`, then `pnpm dev:pocketic` (starts -PocketIC with the backend and a mock LLM, `POCKET_IC_BIN` must point to a PocketIC 16 server, Node -22.18 or later), then `pnpm dev` in another terminal. Sign in as a guest; Internet Identity is not -installed there, so share links need an icp-cli network. - -### The frontend on ic-reactor 4 - -- `src/canisters/backend.ts` is generated from `backend/backend.did` (the Vite plugin runs - `candid-core-cli gen` on start and on every `.did` change). It exports the `actor` schema, `type Actor` - and a type per Candid declaration: `opt T` is `T | null`, variants are `{ tag, value }`, blobs are - `Uint8Array`. CI fails if the committed file does not match the `.did`. -- `src/reactor.ts` creates the tab's client (`createClient({ network: "env", auth })`), which - `ReactorProvider` borrows. Components call `useQuery(client.queryOptions(backend, "list_shares"))` and - `useMutation(client.mutationOptions(backend, "create_note"))`; other code calls the backend directly - (`await backend.get_config()`). A `Result` is unwrapped: an `Err` rejects with a `canister_err` - `ReactorError` whose `err` is the typed `Error` variant. -- `src/lib/sessionAuth.ts` is the app's sign-in: one `AuthLike` over Internet Identity and the guest key, - so `useAuth()`, query keys and the agent all follow whichever is active. -- Share links are opened with a separate anonymous client (`createReaderClient`), so a signed-in reader's - principal is not tied to the share, and the signed-out app client, which refuses updates, is not needed. - -### AI locally +End-to-end encrypted notes on the Internet Computer, with links that burn after reading
+and an AI assistant that runs on your device. -The assistant is off by default. A local network also has no LLM canister: deploy DFINITY's LLM canister -with [Ollama](https://ollama.com) (see the `ic-llm` examples), or any canister that implements `v1_chat`, -then switch it on: +**[Open B3Note](https://note.b3pay.net)** · [How it works](docs/how-it-works.md) · [Run it locally](#run-it-locally) -```bash -icp canister call backend update_config '(record { ai_enabled = opt true; llm_canister = opt principal "" })' -``` - -(`pnpm dev:pocketic` installs a mock LLM and switches the assistant on.) - -When a canister named `llm` is part of the project, `icp deploy` injects `PUBLIC_CANISTER_ID:llm` and the -backend uses it automatically. - -## Deploying to mainnet - -```bash -icp deploy -e production --no-create # both canisters -icp deploy frontend -e production --no-create # the frontend only -``` - -The deploy upgrades the canisters recorded in `.icp/data/mappings/production.ids.json`: backend -`xeka7-ryaaa-aaaal-qb57a-cai` and frontend `4lidq-zqaaa-aaaap-abkbq-cai`. `--no-create` makes it stop -rather than create a canister that is not recorded there. Do not deploy with `-e ic`: that is icp-cli's -built-in mainnet environment, which records none of these ids, so it creates new canisters. A new -frontend is a new origin, which gives every user a new principal, and the custom domain would still -point at the old one. - -The `production` environment uses the vetKD key `key_1`, keeps the AI assistant off and sets strict -limits (`icp.yaml`). The frontend reads the backend's id from the asset canister's `ic_env` cookie on -`*.icp0.io`, and from `frontend/src/lib/origins.ts` on a custom domain ([Custom domain](#custom-domain)). -A build with `CANISTER_ID_BACKEND=` set uses that id everywhere. - -Operating notes: - -- vetKD derivations cost about 0.026 T cycles each on `key_1`. The app derives one key per Internet - Identity user per browser (then caches it), plus one per share view; guests cost nothing. Production - allows 5 derivations per user and 20 in total per hour, so the worst case is about 0.5 T an hour. - Raise the limits as real use grows. -- Controllers can change limits, models and the AI switch at runtime. `limits` replaces the whole - record: +[![CI](https://github.com/B3Pay/B3Note/actions/workflows/ci.yml/badge.svg)](https://github.com/B3Pay/B3Note/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-a35f00)](./LICENSE) +[![Built on the Internet Computer](https://img.shields.io/badge/built_on-Internet_Computer-e8a33d)](https://internetcomputer.org) - ```bash - icp canister call backend get_config '()' -e production - icp canister call backend update_config '(record { ai_enabled = opt true; llm_model = opt "llama4-scout" })' -e production - ``` +
-- The LLM's free models need no cycles. Set `llm_cycles_per_call` for paid models. +
-- **Upgrading from B3Note v1:** v2 has a new stable-memory layout, so install it as a new canister or - reinstall; an in-place upgrade cannot read v1's state. v1 notes cannot be migrated: they were - encrypted with keys from an insecure demo vetKD canister (see below). `scripts/migrate-mainnet.sh` - reinstalls v1's mainnet canisters with v2, keeping their IDs and URL, without taking a snapshot - (there is no way back to v1). Run it with an identity that controls them: - - ```bash - ./scripts/migrate-mainnet.sh preflight # read-only: identity, controllers, cycles (≥ 1 T) - ./scripts/migrate-mainnet.sh migrate # reinstalls backend + frontend with v2, then verifies - ./scripts/migrate-mainnet.sh retire-system-api # deletes v1's insecure vetKD canister, recovering its cycles - ``` - -### Custom domain - -The frontend is also served at `https://note.b3pay.net`, once the steps below are done. Internet Identity -gives a user one principal per origin, so a page there signs in with `derivationOrigin` set to the -canister's own address, `https://4lidq-zqaaa-aaaap-abkbq-cai.icp0.io`: a user has the same principal, and -so the same notes, at both addresses. `frontend/src/lib/origins.ts` holds the production ids and the -custom domains, and the client uses them only on a custom domain: - -- Sign-in: Internet Identity accepts the `derivationOrigin` because the canister's address lists the - domain in `/.well-known/ii-alternative-origins`. Internet Identity's page fetches that file from - another origin, so `.ic-assets.json5` serves it with `Access-Control-Allow-Origin: *`, and keeps the - `.well-known` folder, which the asset canister would otherwise skip. -- Calls go to `https://icp-api.io`, and to the production backend by its id: the `ic_env` cookie is not - read there, because any other subdomain of `b3pay.net` could set it. -- The HTTP gateways serve the domain only if the canister lists it in `/.well-known/ic-domains`. + + + B3Note: a pinned travel checklist open beside the list of notes + -`frontend/tests/customDomain.test.ts` checks that `origins.ts` and both `.well-known` files name the same -domains. To add one, add it to all three, deploy the frontend, check that -`https://4lidq-zqaaa-aaaap-abkbq-cai.icp0.io/.well-known/ii-alternative-origins` returns the JSON (not the -app's HTML), then add these DNS records, as `forge.b3pay.net` has them (on Cloudflare, DNS only, not -proxied): +## Features -| Type | Name | Value | -| ----- | -------------------------------- | ---------------------------------------- | -| CNAME | `note.b3pay.net` | `note.b3pay.net.icp1.io` | -| TXT | `_canister-id.note.b3pay.net` | `4lidq-zqaaa-aaaap-abkbq-cai` | -| CNAME | `_acme-challenge.note.b3pay.net` | `_acme-challenge.note.b3pay.net.icp2.io` | +- **Encrypted in your browser.** Every note is sealed before it leaves your browser, so the canister + only ever stores ciphertext. +- **Links that burn after reading.** With Internet Identity, share a snapshot that opens once, or a few + times, and then can never be decrypted again. +- **AI that stays on your device.** Summarize, tidy, tag or translate a note, and ask questions across + your notes, with a model that runs in your browser. +- **Self-destructing notes.** Give a note a timer and it is deleted when the timer runs out. +- **No email, no password.** Sign in with Internet Identity, or try it as a guest with a key kept in + your browser. +- **Yours to take with you.** Export to JSON or Markdown, import Markdown files, and delete your account + whenever you like. -and register the domain with the boundary nodes: +## How it works -```bash -curl -sL "https://icp.net/custom-domains/v1/note.b3pay.net/validate" # "success" once DNS is right -curl -sL -X POST "https://icp.net/custom-domains/v1/note.b3pay.net" # starts registration -curl -sL "https://icp.net/custom-domains/v1/note.b3pay.net" # until "registered" -``` +Your browser encrypts each note with AES-GCM before sending it. With Internet Identity the key comes +from the Internet Computer's +[vetKeys](https://internetcomputer.org/docs/building-apps/network-features/vetkeys/introduction), +derived for you alone and delivered encrypted to your browser. As a guest, it is derived from a key +kept in your browser. Only the canister can request a share link's key, once per view, and it deletes +the shared copy after the last view, so a burned link stays burned. [Read the full design](docs/how-it-works.md). -A browser keeps two things per origin, so they do not follow a user to the new address: a guest's key -(in localStorage; a guest signs in there by importing their recovery key), and the cached vault key (in -IndexedDB; an Internet Identity user's first unlock there derives it again, which counts toward the -per-user vetKD limit). +## Run it locally -## Testing +You need Rust, Node.js 22 with pnpm, and [icp-cli](https://cli.internetcomputer.org). ```bash -./scripts/test-backend.sh # Rust unit tests + PocketIC integration tests -cd frontend && pnpm test # unit, component and fake-replica tests (vitest) -cd frontend && pnpm test:pic # the frontend's crypto + data layer vs. the real canister +rustup target add wasm32-unknown-unknown +npm i -g @icp-sdk/icp-cli @icp-sdk/ic-wasm +icp network start -d # a local network with Internet Identity +icp deploy # builds and deploys the backend and the frontend ``` -- **Backend unit tests** cover quotas, rate limits, share burning and replay, expiry, config - validation, the Internet Identity key check, AI prompt building, and a check that `backend.did` matches the code - (`UPDATE_CANDID=1 cargo test -p backend` regenerates it). -- **PocketIC tests** (`integration_tests/`) run the real wasm with real vetKD. They decrypt and verify - keys with the `ic-vetkeys` crate and check the canister's public keys against PocketIC's master keys. - They also cover access control (guests get no vetKey or share links), optimistic concurrency, - self-destruct timers, burn-after-reading, replay protection, rate limits, upgrades, install args and the - AI endpoint with a mock LLM canister. -- **Frontend tests** run the real crypto against a fake vetKD with a known master key, the data layer - and sign-in on ic-reactor's test client (a real client over a fake replica, with mocked canisters), - and (`test:pic`) the browser code and ic-reactor clients against the real canister in PocketIC, for - guests and for Internet Identity users. - -The pocket-ic crate downloads its server automatically; set `POCKET_IC_BIN` to use your own (16.x). CI -runs all of the above (`.github/workflows/ci.yml`). +Then open the `frontend` URL that `icp deploy` prints. -## What v2 fixes +## Documentation -B3Note v1 had security and correctness problems serious enough that v2 is a rewrite: +- **[How it works](docs/how-it-works.md):** the encryption, share links, the on-device AI, and what the + canister can see +- **[Development](docs/development.md):** repository layout, hot reload, UI work without icp-cli, the AI + locally, and tests +- **[Operations](docs/operations.md):** deploying to mainnet, limits and costs, and the custom domain +- **[B3Note v2](docs/v2.md):** what v2 fixed in v1, and how mainnet moved to v2 -- **Notes were not private.** v1 used DFINITY's *unsafe example* vetKD system-API canister, whose master - secret key is in its source. It also encrypted every note to the anonymous principal's identity and - gave that decryption key to any anonymous caller, while `encrypted_texts()` returned everyone's - ciphertext. -- **Anyone could overwrite any note.** `edit_encrypted_text` never checked ownership. -- **Internet Identity users could not read their notes.** They were encrypted to one identity and - decrypted with another. Signing in also replaced the user's record and lost their note list, and share - links never worked for them. -- **Writes were silently dropped.** Updates to stable-map entries were made on copies and never written - back, so the "3 tries" limit on links never applied. Writes inside query methods were discarded. -- **Cleanup stopped after upgrades.** Timers were not rescheduled. Expired anonymous users left their - ciphertext behind forever, and `add_simple_note` let anyone grow an unbounded heap map. -- **Other defects:** fixed-size storable bounds that trapped on larger values, an off-by-one note limit, - an un-awaited `fetchRootKey`, a hard-coded host, and an empty LICENSE file. +## Built with -v2 uses the IC's native vetKD, ic-cdk 0.20, ic-stable-structures 0.7, icp-cli, ic-reactor 4 (beta) and -`@icp-sdk/*`. +[Internet Computer](https://internetcomputer.org) · +[vetKeys](https://internetcomputer.org/docs/building-apps/network-features/vetkeys/introduction) · +Rust · React 19 · Tailwind CSS 4 · [ic-reactor](https://github.com/B3Pay/ic-reactor) · +[icp-cli](https://cli.internetcomputer.org) · [WebLLM](https://github.com/mlc-ai/web-llm) ## License diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..6f0de4c --- /dev/null +++ b/docs/development.md @@ -0,0 +1,93 @@ +# Developing B3Note + +## Repository layout + +``` +backend/ Rust canister (ic-cdk 0.20, ic-stable-structures 0.7, vetKD, ic-llm) + backend.did Candid interface (checked by a unit test, see below) +mock_llm/ Test double of the LLM canister's v1_chat endpoint +integration_tests/ PocketIC tests with real vetKD (keys verified with the ic-vetkeys crate) +frontend/ Vite + React 19 + Tailwind v4 app on ic-reactor 4 (beta) and @icp-sdk/* + src/canisters/ Generated from backend.did by @ic-reactor/vite-plugin (candid-core) + src/reactor.ts The ic-reactor client, its sign-in, and the backend handle + src/lib/ Crypto (vault, share links, key pinning), note format, search, sign-in + tests/ Test-client tests and the PicJS suite against the real canister +icp.yaml icp-cli project: backend, frontend, local network with Internet Identity +scripts/ build-canisters.sh, test-backend.sh, migrate-mainnet.sh +docs/ How it works, development, operations, v2 +``` + +## Getting started + +Prerequisites: Rust with the `wasm32-unknown-unknown` target, Node.js 22 with pnpm, and +[icp-cli](https://cli.internetcomputer.org) with `ic-wasm` +(`npm i -g @icp-sdk/icp-cli @icp-sdk/ic-wasm`). + +```bash +rustup target add wasm32-unknown-unknown +icp network start -d # a local network with Internet Identity and the vetKD test keys +icp deploy # builds and deploys backend + frontend +``` + +Open the `frontend` URL that `icp deploy` prints. For hot reload, run `pnpm dev` in `frontend/`. The +ic-reactor Vite plugin reads canister ids and the root key from the local network. + +**Without icp-cli** (UI work only): `./scripts/build-canisters.sh`, then `pnpm dev:pocketic` (starts +PocketIC with the backend and a mock LLM, `POCKET_IC_BIN` must point to a PocketIC 16 server binary named +`pocket-ic`, Node 22.18 or later), then `pnpm dev` in another terminal. Sign in as a guest; Internet Identity is not +installed there, so share links need an icp-cli network. + +## The frontend on ic-reactor 4 + +- `src/canisters/backend.ts` is generated from `backend/backend.did` (the Vite plugin runs + `candid-core-cli gen` on start and on every `.did` change). It exports the `actor` schema, `type Actor` + and a type per Candid declaration: `opt T` is `T | null`, variants are `{ tag, value }`, blobs are + `Uint8Array`. CI fails if the committed file does not match the `.did`. +- `src/reactor.ts` creates the tab's client (`createClient({ network: "env", auth })`), which + `ReactorProvider` borrows. Components call `useQuery(client.queryOptions(backend, "list_shares"))` and + `useMutation(client.mutationOptions(backend, "create_note"))`; other code calls the backend directly + (`await backend.get_config()`). A `Result` is unwrapped: an `Err` rejects with a `canister_err` + `ReactorError` whose `err` is the typed `Error` variant. +- `src/lib/sessionAuth.ts` is the app's sign-in: one `AuthLike` over Internet Identity and the guest key, + so `useAuth()`, query keys and the agent all follow whichever is active. +- Share links are opened with a separate anonymous client (`createReaderClient`), so a signed-in reader's + principal is not tied to the share, and the signed-out app client, which refuses updates, is not needed. + +## AI locally + +The assistant is off by default. A local network also has no LLM canister: deploy DFINITY's LLM canister +with [Ollama](https://ollama.com) (see the `ic-llm` examples), or any canister that implements `v1_chat`, +then switch it on: + +```bash +icp canister call backend update_config '(record { ai_enabled = opt true; llm_canister = opt principal "" })' +``` + +(`pnpm dev:pocketic` installs a mock LLM and switches the assistant on.) + +When a canister named `llm` is part of the project, `icp deploy` injects `PUBLIC_CANISTER_ID:llm` and the +backend uses it automatically. + +## Testing + +```bash +./scripts/test-backend.sh # Rust unit tests + PocketIC integration tests +cd frontend && pnpm test # unit, component and fake-replica tests (vitest) +cd frontend && pnpm test:pic # the frontend's crypto + data layer vs. the real canister +``` + +- **Backend unit tests** cover quotas, rate limits, share burning and replay, expiry, config + validation, the Internet Identity key check, AI prompt building, and a check that `backend.did` matches the code + (`UPDATE_CANDID=1 cargo test -p backend` regenerates it). +- **PocketIC tests** (`integration_tests/`) run the real wasm with real vetKD. They decrypt and verify + keys with the `ic-vetkeys` crate and check the canister's public keys against PocketIC's master keys. + They also cover access control (guests get no vetKey or share links), optimistic concurrency, + self-destruct timers, burn-after-reading, replay protection, rate limits, upgrades, install args and the + AI endpoint with a mock LLM canister. +- **Frontend tests** run the real crypto against a fake vetKD with a known master key, the data layer + and sign-in on ic-reactor's test client (a real client over a fake replica, with mocked canisters), + and (`test:pic`) the browser code and ic-reactor clients against the real canister in PocketIC, for + guests and for Internet Identity users. + +The pocket-ic crate downloads its server automatically; set `POCKET_IC_BIN` to use your own (16.x). CI +runs all of the above (`.github/workflows/ci.yml`). diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..57ef99b --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,57 @@ +# How B3Note keeps your notes private + +Notes are encrypted in your browser before they are sent, so the canister only ever stores ciphertext. +With Internet Identity the key is derived from your identity by the IC's +[vetKeys](https://internetcomputer.org/docs/building-apps/network-features/vetkeys/introduction); as a +guest it is derived from your guest key. This page describes each part. + +``` + browser backend canister vetKD (IC) + ┌──────────────────────────────┐ get_encrypted_user_key(tpk) ┌──────────────┐ vetkd_derive_key + │ transport key pair (tsk, tpk)│ ─────────────────────────────▶ │ input = │ ───────────────────▶ + │ │ ◀───────────────────────────── │ caller │ ◀─── key encrypted + │ decrypt + verify the vetKey │ encrypted vetKey + public key│ principal │ to tpk + │ → AES-GCM key (non-extract.) │ └──────────────┘ + │ encrypt(note, AD = note id) │ ── create_note(ciphertext) ──▶ stores ciphertext only + └──────────────────────────────┘ +``` + +- **Notes (Internet Identity).** Each user's vetKey is derived from their principal (context + `b3note/user-key/v1`). Only that principal can request it, and it reaches the browser encrypted to a + one-time transport key. The browser verifies it, turns it into a non-extractable AES-GCM key (cached in + IndexedDB) and encrypts each note with the note id as associated data. A server that swaps ciphertexts + between notes therefore gets a decryption error, not the wrong note. +- **Notes (guests).** A guest key is already a secret only its holder has, so the browser derives the + note key from it (HKDF-SHA-256, salt `b3note/guest-key/v1`, info = the canister id) and encrypts the + same way. It costs the canister nothing, and the recovery key restores it on any device. +- **Who gets vetKD.** Every derivation costs the canister cycles, so `get_encrypted_user_key` and + `create_share` are for Internet Identity accounts only. The client sends the root public key of its + delegation chain; the canister checks that it hashes to the caller's principal and that it is a + canister-signature key of Internet Identity (`rdmx6-jaaaa-aaaaa-aaadq-cai`). Opening a share needs no + account, and is bounded by the share's views and the global hourly budget. +- **Key pinning.** On mainnet the app derives the canister's vetKD public keys from the IC's published + master key and refuses keys that do not match. +- **Share links.** The browser IBE-encrypts a snapshot to the identity *share id* under the canister's + share key (context `b3note/share/v1`). It also makes a fresh Ed25519 key, uploads the public half, and + puts the secret in the link's `#fragment`, which browsers never send to a server. To open, the reader + signs `(domain, share id, transport key)`. The canister checks the signature, spends a view, and has the + share key derived and encrypted to the reader. A transport key can be used once per share, so a + replayed request cannot burn another view. +- **On-device AI.** The model reads the note in the browser that decrypted it. The only network traffic + is the model's files on first use (Hugging Face and the MLC model library on GitHub), which is why the + page's CSP allows those hosts and `'wasm-unsafe-eval'` for the model's WebAssembly. Both are pinned to a + commit, and WebLLM checks the model's config, its tokenizer and its WebAssembly library against hashes + before it runs them (`frontend/src/lib/localAi.ts`), so a push to either repository cannot change the + code that reads your notes. +- **No remote images.** Fetching an image sends its URL to its host, so a note's Markdown or an AI answer + (which a prompt hidden in a note could steer) could carry text out that way, and an image in a shared + note would tell its sender when it was opened. Markdown shows images as links instead, and the CSP's + `img-src` allows only the app itself. +- **AI on the canister (when switched on).** The assistant needs plaintext. Only the text you pick for an action (or, + for *Ask your notes*, the few notes your browser ranks as relevant) goes to the LLM canister. + Everything else stays encrypted. + +## What the canister sees + +What the canister does see: note sizes, timestamps, versions and expiry times, the number of notes, and +which principal owns them. diff --git a/docs/images/app-dark.png b/docs/images/app-dark.png new file mode 100644 index 0000000..0786837 Binary files /dev/null and b/docs/images/app-dark.png differ diff --git a/docs/images/app-light.png b/docs/images/app-light.png new file mode 100644 index 0000000..d35b1ea Binary files /dev/null and b/docs/images/app-light.png differ diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..8797a45 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,77 @@ +# Running B3Note on mainnet + +## Deploying + +```bash +icp deploy -e production --no-create # both canisters +icp deploy frontend -e production --no-create # the frontend only +``` + +The deploy upgrades the canisters recorded in `.icp/data/mappings/production.ids.json`: backend +`xeka7-ryaaa-aaaal-qb57a-cai` and frontend `4lidq-zqaaa-aaaap-abkbq-cai`. `--no-create` makes it stop +rather than create a canister that is not recorded there. Do not deploy with `-e ic`: that is icp-cli's +built-in mainnet environment, which records none of these ids, so it creates new canisters. A new +frontend is a new origin, which gives every user a new principal, and the custom domain would still +point at the old one. + +The `production` environment uses the vetKD key `key_1`, keeps the AI assistant off and sets strict +limits (`icp.yaml`). The frontend reads the backend's id from the asset canister's `ic_env` cookie on +`*.icp0.io`, and from `frontend/src/lib/origins.ts` on a custom domain ([Custom domain](#custom-domain)). +A build with `CANISTER_ID_BACKEND=` set uses that id everywhere. + +## Limits, costs and settings + +- vetKD derivations cost about 0.026 T cycles each on `key_1`. The app derives one key per Internet + Identity user per browser (then caches it), plus one per share view; guests cost nothing. Production + allows 5 derivations per user and 20 in total per hour, so the worst case is about 0.5 T an hour. + Raise the limits as real use grows. +- Controllers can change limits, models and the AI switch at runtime. `limits` replaces the whole + record: + + ```bash + icp canister call backend get_config '()' -e production + icp canister call backend update_config '(record { ai_enabled = opt true; llm_model = opt "llama4-scout" })' -e production + ``` + +- The LLM's free models need no cycles. Set `llm_cycles_per_call` for paid models. + +## Custom domain + +The frontend is also served at `https://note.b3pay.net`. Internet Identity +gives a user one principal per origin, so a page there signs in with `derivationOrigin` set to the +canister's own address, `https://4lidq-zqaaa-aaaap-abkbq-cai.icp0.io`: a user has the same principal, and +so the same notes, at both addresses. `frontend/src/lib/origins.ts` holds the production ids and the +custom domains, and the client uses them only on a custom domain: + +- Sign-in: Internet Identity accepts the `derivationOrigin` because the canister's address lists the + domain in `/.well-known/ii-alternative-origins`. Internet Identity's page fetches that file from + another origin, so `.ic-assets.json5` serves it with `Access-Control-Allow-Origin: *`, and keeps the + `.well-known` folder, which the asset canister would otherwise skip. +- Calls go to `https://icp-api.io`, and to the production backend by its id: the `ic_env` cookie is not + read there, because any other subdomain of `b3pay.net` could set it. +- The HTTP gateways serve the domain only if the canister lists it in `/.well-known/ic-domains`. + +`frontend/tests/customDomain.test.ts` checks that `origins.ts` and both `.well-known` files name the same +domains. To add one, add it to all three, deploy the frontend, check that +`https://4lidq-zqaaa-aaaap-abkbq-cai.icp0.io/.well-known/ii-alternative-origins` returns the JSON (not the +app's HTML), then add these DNS records, as `forge.b3pay.net` has them (on Cloudflare, DNS only, not +proxied): + +| Type | Name | Value | +| ----- | -------------------------------- | ---------------------------------------- | +| CNAME | `note.b3pay.net` | `note.b3pay.net.icp1.io` | +| TXT | `_canister-id.note.b3pay.net` | `4lidq-zqaaa-aaaap-abkbq-cai` | +| CNAME | `_acme-challenge.note.b3pay.net` | `_acme-challenge.note.b3pay.net.icp2.io` | + +and register the domain with the boundary nodes: + +```bash +curl -sL "https://icp.net/custom-domains/v1/note.b3pay.net/validate" # "success" once DNS is right +curl -sL -X POST "https://icp.net/custom-domains/v1/note.b3pay.net" # starts registration +curl -sL "https://icp.net/custom-domains/v1/note.b3pay.net" # until "registered" +``` + +A browser keeps two things per origin, so they do not follow a user to the new address: a guest's key +(in localStorage; a guest signs in there by importing their recovery key), and the cached vault key (in +IndexedDB; an Internet Identity user's first unlock there derives it again, which counts toward the +per-user vetKD limit). diff --git a/docs/v2.md b/docs/v2.md new file mode 100644 index 0000000..256fd69 --- /dev/null +++ b/docs/v2.md @@ -0,0 +1,37 @@ +# B3Note v2 + +## What v2 fixes + +B3Note v1 had security and correctness problems serious enough that v2 is a rewrite: + +- **Notes were not private.** v1 used DFINITY's *unsafe example* vetKD system-API canister, whose master + secret key is in its source. It also encrypted every note to the anonymous principal's identity and + gave that decryption key to any anonymous caller, while `encrypted_texts()` returned everyone's + ciphertext. +- **Anyone could overwrite any note.** `edit_encrypted_text` never checked ownership. +- **Internet Identity users could not read their notes.** They were encrypted to one identity and + decrypted with another. Signing in also replaced the user's record and lost their note list, and share + links never worked for them. +- **Writes were silently dropped.** Updates to stable-map entries were made on copies and never written + back, so the "3 tries" limit on links never applied. Writes inside query methods were discarded. +- **Cleanup stopped after upgrades.** Timers were not rescheduled. Expired anonymous users left their + ciphertext behind forever, and `add_simple_note` let anyone grow an unbounded heap map. +- **Other defects:** fixed-size storable bounds that trapped on larger values, an off-by-one note limit, + an un-awaited `fetchRootKey`, a hard-coded host, and an empty LICENSE file. + +v2 uses the IC's native vetKD, ic-cdk 0.20, ic-stable-structures 0.7, icp-cli, ic-reactor 4 (beta) and +`@icp-sdk/*`. + +## Upgrading from v1 + +v2 has a new stable-memory layout, so install it as a new canister or reinstall; an in-place upgrade +cannot read v1's state. v1 notes cannot be migrated: they were encrypted with keys from an insecure demo +vetKD canister (see above). `scripts/migrate-mainnet.sh` reinstalls v1's mainnet canisters with v2, +keeping their IDs and URL, without taking a snapshot (there is no way back to v1). Run it with an +identity that controls them: + +```bash +./scripts/migrate-mainnet.sh preflight # read-only: identity, controllers, cycles (≥ 1 T) +./scripts/migrate-mainnet.sh migrate # reinstalls backend + frontend with v2, then verifies +./scripts/migrate-mainnet.sh retire-system-api # deletes v1's insecure vetKD canister, recovering its cycles +``` diff --git a/frontend/public/.ic-assets.json5 b/frontend/public/.ic-assets.json5 index 03a323b..1e57d01 100644 --- a/frontend/public/.ic-assets.json5 +++ b/frontend/public/.ic-assets.json5 @@ -28,7 +28,7 @@ match: "index.html", headers: { "Cache-Control": "no-cache" }, }, - // The custom domain's files (README, "Custom domain"). The asset canister + // The custom domain's files (docs/operations.md, "Custom domain"). The asset canister // skips hidden folders unless a rule says otherwise. The HTTP gateways read // ic-domains; Internet Identity's page fetches ii-alternative-origins from // another origin, so that file needs CORS. diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx index d596660..1f2ada6 100644 --- a/frontend/src/components/Markdown.tsx +++ b/frontend/src/components/Markdown.tsx @@ -14,7 +14,7 @@ const REMARK_PLUGINS = [remarkGfm, remarkBreaks] const components: Components = { a: ({ node: _node, ...props }) => , - // Images are not loaded (README, "How the encryption works"): fetching one + // Images are not loaded (docs/how-it-works.md): fetching one // tells its host that the note was opened, and its URL can carry text out, // for example when an AI answer is steered into writing one. The CSP's // img-src refuses them too. The image stays as a link to it. diff --git a/frontend/src/lib/origins.ts b/frontend/src/lib/origins.ts index b5f9267..e535715 100644 --- a/frontend/src/lib/origins.ts +++ b/frontend/src/lib/origins.ts @@ -1,6 +1,6 @@ /** * B3Note's production canisters and the custom domains that serve its - * frontend (README, "Custom domain"). + * frontend (docs/operations.md, "Custom domain"). * * Internet Identity derives a principal per origin, and counts a canister's * gateway addresses (`icp0.io`, `ic0.app`, `icp.net`) as one. A custom domain diff --git a/frontend/tests/customDomain.test.ts b/frontend/tests/customDomain.test.ts index bf12f2d..58f1a24 100644 --- a/frontend/tests/customDomain.test.ts +++ b/frontend/tests/customDomain.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom /** - * B3Note at a custom domain (README, "Custom domain"): which origins count as + * B3Note at a custom domain (docs/operations.md, "Custom domain"): which origins count as * one, the files the HTTP gateways and Internet Identity read, and the app's * client on each address that serves it. */ diff --git a/frontend/tests/production.test.ts b/frontend/tests/production.test.ts index b8810f6..851b6b7 100644 --- a/frontend/tests/production.test.ts +++ b/frontend/tests/production.test.ts @@ -1,5 +1,5 @@ /** - * B3Note's mainnet canisters (README, "Deploying to mainnet"). icp-cli deploys + * B3Note's mainnet canisters (docs/operations.md, "Deploying"). icp-cli deploys * the `production` environment to the ids in `.icp/data/mappings`, and creates * a new canister for any it cannot find there. A new frontend is a new origin, * which gives every user a new principal, so the ids recorded there must stay diff --git a/icp.yaml b/icp.yaml index 6a5fddd..601367c 100644 --- a/icp.yaml +++ b/icp.yaml @@ -2,7 +2,7 @@ # # B3Note: `icp network start -d && icp deploy` for a local deployment, # `icp deploy -e production --no-create` for mainnet, never `-e ic` -# (README, "Deploying to mainnet"). +# (docs/operations.md, "Deploying"). canisters: - name: backend diff --git a/scripts/migrate-mainnet.sh b/scripts/migrate-mainnet.sh index 2f00452..505d63f 100755 --- a/scripts/migrate-mainnet.sh +++ b/scripts/migrate-mainnet.sh @@ -42,7 +42,7 @@ field() { node -e "const s = JSON.parse(require('fs').readFileSync(0, 'utf8')); preflight() { say "Tools" for tool in icp ic-wasm cargo pnpm node curl; do - command -v "$tool" >/dev/null || die "$tool is not installed (see README: Getting started)" + command -v "$tool" >/dev/null || die "$tool is not installed (see docs/development.md: Getting started)" done icp --version