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
+
-
+
-**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:
+[](https://github.com/B3Pay/B3Note/actions/workflows/ci.yml)
+[](./LICENSE)
+[](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`.
+
+
+
+
-`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