Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
316 changes: 53 additions & 263 deletions README.md
100755 → 100644

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
@@ -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 "<id>" })'
```

(`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`).
57 changes: 57 additions & 0 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added docs/images/app-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/app-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
77 changes: 77 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
@@ -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=<id>` 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).
37 changes: 37 additions & 0 deletions docs/v2.md
Original file line number Diff line number Diff line change
@@ -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
```
2 changes: 1 addition & 1 deletion frontend/public/.ic-assets.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const REMARK_PLUGINS = [remarkGfm, remarkBreaks]

const components: Components = {
a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
// 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.
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/origins.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/tests/customDomain.test.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down
2 changes: 1 addition & 1 deletion frontend/tests/production.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion icp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/migrate-mainnet.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading