From 25895323a2174ee42896a8bd832d59c1bb1a6b72 Mon Sep 17 00:00:00 2001 From: josephchimebuka Date: Thu, 22 Oct 2026 05:41:06 +0000 Subject: [PATCH] docs: add landing, style guide, identity trust, and MLS client docs (#630 #640 #626 #627) Co-authored-by: Cursor --- apps/web/docs/components-landing.md | 192 +++++++++++++++++++++ apps/web/docs/concepts-identity-trust.md | 206 +++++++++++++++++++++++ apps/web/docs/concepts-mls-client.md | 140 +++++++++++++++ apps/web/docs/crypto-module-map.md | 2 +- apps/web/src/lib/mls-integration.md | 26 --- docs/README.md | 8 +- docs/adr/0006-mls-for-groups.md | 2 +- docs/documentation-style-guide.md | 110 ++++++++++++ 8 files changed, 657 insertions(+), 29 deletions(-) create mode 100644 apps/web/docs/components-landing.md create mode 100644 apps/web/docs/concepts-identity-trust.md create mode 100644 apps/web/docs/concepts-mls-client.md delete mode 100644 apps/web/src/lib/mls-integration.md create mode 100644 docs/documentation-style-guide.md diff --git a/apps/web/docs/components-landing.md b/apps/web/docs/components-landing.md new file mode 100644 index 00000000..e5861401 --- /dev/null +++ b/apps/web/docs/components-landing.md @@ -0,0 +1,192 @@ +# Landing page components + +Reference for `apps/web/src/components/landing/`. The public landing page is composed in +[`apps/web/src/app/page.tsx`](../src/app/page.tsx), which mounts these seven components in +order: `Navbar`, `Hero`, `Features`, `HowItWorks`, `TechStack`, `CTA`, `Footer`. The page +also mounts `ToastDemo` between `TechStack` and `CTA`. That component is not part of +`landing/` and is not covered here. + +Every component in this folder is presentational. None of them take props. Copy and links +are string literals in the same file as the component (or in a `const` array at the top of +that file). There is no i18n catalog and no shared copy module. + +> **Stale when:** a string in the [claims to correct](#claims-that-currently-overstate) table +> is edited or removed. Update that row in the same change. + +## Accuracy rule + +The landing page makes public claims about the product. A feature or technology named here +must match what is shipped in the current code. Do not describe a phase, a dependency, or a +roadmap item as if it were the live path. + +The live message-encryption default is `Phase1SessionCrypto` in +[`apps/web/src/lib/session.ts`](../src/lib/session.ts) (sealed-box: WebCrypto ECDH + HKDF + +AES-256-GCM). `LibsignalSessionCrypto` exists in the same file and is not assigned to +`defaultSession`. The Signal protocol is not the live default. See +[Signal integration](../../../docs/signal-integration.md) for the swap that has not been made. + +## Claims that currently overstate + +These strings are in the components today. Correct the copy; do not "fix" this list by +softening the description of the code. + +| Where | Claim in the component | What the code does | +| --- | --- | --- | +| `TechStack.tsx` `STACK` | `XMTP` under Messaging | Nothing in the app imports or calls XMTP. `XMTP_ENV` is unused ([environment variables](../../../docs/environment-variables.md)). Live encryption is `Phase1SessionCrypto`, not XMTP and not Signal. | +| `Features.tsx` `FEATURES` | "DAO-style Voting" — "One address, one voice — or weighted by contribution." | [`contracts/contracts/proposals/src/lib.rs`](../../../contracts/contracts/proposals/src/lib.rs) `vote` records one unweighted boolean per address (`yes_votes` or `no_votes` increments by 1). There is no contribution weight. | +| `Features.tsx` `FEATURES` | "AI-powered Insights" — "Fraud detection, proposal summarisation, and smart assistants baked into the conversation layer." | `apps/ai_agent` exposes summarise and transfer-risk HTTP APIs. No file under `apps/web/src` except this landing copy calls them. They are not wired into the conversation page. | +| `Features.tsx` `FEATURES` | "Send Tokens in Chat" — "Transfer XLM or any Soroban token inside a conversation." | In-chat transfer receipts render only when a message body parses as a transfer payload (`parseTransferPayload` in the conversation page). The composer has no payment control. Sending is `transferToken()` through Freighter ([token transfer flow](../../../docs/flows/token-transfer.md)), not an arbitrary Soroban token picked in the thread. | +| `Features.tsx` `FEATURES` | "Wallet-to-Wallet Messaging" — "No email, no username — just your public key." | Sign-in is a wallet signature plus a device identity key ([auth and device lifecycle](./concepts-auth-device-lifecycle.md)). Users have an optional `username`. The conversation header prefers `username` over the wallet address. | +| `Hero.tsx` | "decentralized messaging platform" | The client encrypts before upload. Delivery and ciphertext storage are the backend relay, not a decentralized messaging network. | +| `HowItWorks.tsx` `STEPS` step 02 | "Conversations are end-to-end linked to on-chain identities." | Encryption targets device identity public keys held by the backend. A conversation is not an on-chain identity record. | +| `HowItWorks.tsx` `STEPS` step 03 | "Type a transfer command or tap the payment button." | The conversation composer is a text field, an attach button, and Send. There is no transfer-command parser and no payment button on that page. | +| `HowItWorks.tsx` `STEPS` step 04 | "all without leaving the conversation." | Proposals and treasury UI are [`apps/web/src/app/app/treasury/page.tsx`](../src/app/app/treasury/page.tsx), a separate route. | +| `Footer.tsx` | `MIT License` | There is no `LICENSE` file in the repo. `apps/backend/package.json` sets `"license": "ISC"`. The footer string is plain text, not a link to a license. | + +Claims that match the current stack closely enough to leave alone: Next.js 16 (`next` 16.2.0 +in `apps/web/package.json`), Tailwind CSS v4, Express 5 (`express` ^5.2.1 in +`apps/backend/package.json`), Socket.IO, PostgreSQL, Redis, Soroban, the Stellar SDK, +FastAPI, Weaviate, and Turborepo. "Built on Stellar · Powered by Soroban" and the Freighter +sign-in step match the wallet path. + +## Responsive breakpoints and dark mode + +Breakpoint and dark-mode rules for the whole web app are in the styling guide +([#346](https://github.com/codebestia/clicked/issues/346)): +[Tailwind and styling conventions](./styling.md#4-responsive-breakpoints) and +[dark mode](./styling.md#7-dark-mode). + +Short version, as it applies here: + +- Tailwind defaults, mobile-first. This folder uses `sm` (640px) and `lg` (1024px) only. + It does not use `md`, `xl`, or `2xl`. +- The page is dark-only. Colours are the `:root` tokens in + [`apps/web/src/app/globals.css`](../src/app/globals.css) (`--background`, `--foreground`, + `--accent`, `--accent-light`, `--muted`, `--card`, `--border`), read as + `bg-[var(--token)]` / `text-[var(--token)]`. There is no theme toggle, no + `prefers-color-scheme` block, and no `dark:` class in this folder. Do not add `dark:` + variants. + +| Component | Breakpoint behaviour | +| --- | --- | +| `Navbar` | Anchor links are `hidden` until `sm:flex`. "Launch App" stays visible at all widths. | +| `Hero` | Headline steps `text-5xl` → `sm:text-6xl` → `lg:text-7xl`. The two buttons stack until `sm:flex-row`. | +| `Features` | Heading `text-3xl` → `sm:text-4xl`. Cards are one column, then `sm:grid-cols-2`, then `lg:grid-cols-3`. | +| `HowItWorks` | Heading `text-3xl` → `sm:text-4xl`. Steps are one column, then `sm:grid-cols-2`, then `lg:grid-cols-4`. The step connector line is `hidden` until `lg:block`. | +| `TechStack` | Heading `text-3xl` → `sm:text-4xl`. Chips wrap with `flex-wrap` at every width. | +| `CTA` | Heading `text-3xl` → `sm:text-4xl`. | +| `Footer` | Column until `sm:flex-row`. | + +## Navbar + +`apps/web/src/components/landing/Navbar.tsx` + +Fixed top bar. Brand wordmark, in-page anchors, and the entry to the app. + +**Props:** none. + +**Copy and links** (all inline in the JSX): + +| Text | href | +| --- | --- | +| `clicked.` | none (not a link) | +| Features | `#features` | +| How it works | `#how-it-works` | +| Tech | `#tech` | +| Launch App | `/app` | + +## Hero + +`apps/web/src/components/landing/Hero.tsx` + +Full-viewport introduction: eyebrow, headline, one paragraph, two actions, and a static +fake thread. The thread is not connected to the messaging API. + +**Props:** none. `ChatBubble` is a private function in this file, not exported. Its props +are `align: 'left' | 'right'`, `name: string`, `message: string`, and optional +`highlight?: boolean`. The three bubbles and their names and messages are inline JSX, not +a data array. + +**Copy and links:** + +| Text | Where | +| --- | --- | +| `Built on Stellar · Powered by Soroban` | eyebrow `` | +| `Chat. Pay. Build together.` | `

` | +| The "decentralized messaging platform…" paragraph | the `

` under the heading | +| Launch App | `href="/app"` | +| View on GitHub | `href="https://github.com/codebestia/clicked"` (`target="_blank"`) | +| `builders-dao`, `4 members`, `alice.xlm`, `bob.xlm`, `You`, and the three bubble strings | mock preview only | + +## Features + +`apps/web/src/components/landing/Features.tsx` + +Section `id="features"`. Heading plus six cards. + +**Props:** none. + +**Copy:** the heading "Everything in one place" and the subtitle under it are inline JSX. +Card icon, title, and description live in the `FEATURES` array at the top of the file. +Edit that array to change a card. Titles today: Wallet-to-Wallet Messaging, Send Tokens in +Chat, Group Treasuries, Community Proposals, DAO-style Voting, AI-powered Insights. + +**Links:** none. The section is the target of Navbar's `#features` anchor. + +## HowItWorks + +`apps/web/src/components/landing/HowItWorks.tsx` + +Section `id="how-it-works"`. Four numbered steps. + +**Props:** none. + +**Copy:** the heading "How it works" and the subtitle are inline JSX. Step number, title, +and description live in the `STEPS` array (`01`–`04`). + +**Links:** none. The section is the target of Navbar's `#how-it-works` anchor. + +## TechStack + +`apps/web/src/components/landing/TechStack.tsx` + +Section `id="tech"`. A wrap of technology chips. + +**Props:** none. + +**Copy:** the heading "Built on proven technology" and the subtitle are inline JSX. Chip +label and category live in the `STACK` array. Chip colours are the `CATEGORY_COLORS` map +in the same file (raw Tailwind palette classes, not the `globals.css` tokens). + +**Links:** none. The section is the target of Navbar's `#tech` anchor. + +## CTA + +`apps/web/src/components/landing/CTA.tsx` + +Closing band under the fold. + +**Props:** none. + +**Copy and links** (inline JSX): + +| Text | href | +| --- | --- | +| `Ready to coordinate on-chain?` | none | +| `Connect your Stellar wallet and start building with your community.` | none | +| Launch App | `/app` | + +## Footer + +`apps/web/src/components/landing/Footer.tsx` + +**Props:** none. + +**Copy and links** (inline JSX): + +| Text | href | +| --- | --- | +| `clicked. — Web3 Social Messaging` | none | +| GitHub | `https://github.com/codebestia/clicked` | +| Stellar | `https://stellar.org` | +| `MIT License` | none (plain text, not a link) | diff --git a/apps/web/docs/concepts-identity-trust.md b/apps/web/docs/concepts-identity-trust.md new file mode 100644 index 00000000..f0828ae5 --- /dev/null +++ b/apps/web/docs/concepts-identity-trust.md @@ -0,0 +1,206 @@ +# Concepts: identity trust, TOFU, and safety numbers + +The server checks that uploaded keys have the shape it expects +(`apps/backend/src/lib/keys.ts`). It does not store, and does not check, "is this still +the identity key this browser trusts." That decision is client-side trust-on-first-use +(TOFU), the same idea as Signal. + +Two separate client mechanisms exist today. They do not share a record. + +| Mechanism | File | What it decides | +| --- | --- | --- | +| Trusted snapshot | [`apps/web/src/lib/identityTrust.ts`](../src/lib/identityTrust.ts) | Whether `assertDevicesTrusted` will encrypt to the device set the server just returned. | +| Safety-number panel | [`apps/web/src/app/app/conversations/[id]/page.tsx`](../src/app/app/conversations/[id]/page.tsx) | Whether the UI shows a "Safety number changed" warning for a contact. | + +The comment at the top of `identityTrust.ts` says the panel reads and writes the same +record as the encryption pin. The page does not import `identityTrust.ts`. It uses a +different `localStorage` key. Treat the comment as stale relative to the page. + +The non-technical companion is +[Devices, safety numbers, and privacy](../../../docs/user-guide/devices-and-privacy.md). +Where that guide says sending pauses until **Mark verified**, the page does not do that. +This document follows the code. + +> **Stale when:** `defaultSession` in `apps/web/src/lib/session.ts` is switched from +> `Phase1SessionCrypto` to `LibsignalSessionCrypto`, or the conversation page starts +> calling `trustDevices`. Update the live-path and "what the user is shown" sections in +> that change. + +## Trusted snapshot + +A snapshot is the device set pinned for one user id: + +```ts +interface TrustedDevice { + id: string; + identityPublicKey: string; +} + +interface TrustedSnapshot { + devices: TrustedDevice[]; + trustedAt: string; // ISO timestamp +} +``` + +`devices` is stored sorted by `id`. + +| Function | Behaviour | +| --- | --- | +| `getTrustedSnapshot(userId)` | Returns the parsed snapshot, or `null` if the key is missing or the JSON is not `{ devices: array, trustedAt: string }`. A parse error also returns `null`. | +| `trustDevices(userId, devices)` | Writes a new snapshot with `trustedAt` set to now. This is the only way the pin moves forward. | +| `clearTrustedSnapshot(userId)` | Deletes the pin. Nothing in the conversation page calls it. | +| `checkIdentityChange(userId, devices)` | Compares `devices` to the pin. See below. | + +`checkIdentityChange` is synchronous. Callers are expected to have already fetched the +live device set (the send path uses `GET /conversations/:id/devices`). + +| Result `status` | When | Side effect | +| --- | --- | --- | +| `first-contact` | `getTrustedSnapshot` returned `null` | Calls `trustDevices` with the live set, then returns. `changedDeviceIds` is `[]`. | +| `trusted` | The pin and the live set have the same fingerprint | None. | +| `changed` | The fingerprints differ | Does **not** update the pin. Calls every `onSessionReset` subscriber with the changed device ids, then returns those ids. | + +The fingerprint used for the comparison is not the safety number. It is +`id:identityPublicKey` for each device, sorted by `id`, joined with `|`. A change is any +of: a device id whose `identityPublicKey` differs, a device id present in the live set +but not in the pin, or a device id present in the pin but missing from the live set. +Order does not matter. + +### Where the snapshot is persisted + +`localStorage` key `clicked.identityTrust.${userId}`. The value is +`JSON.stringify` of the snapshot. + +If `window.localStorage` is missing (SSR, a Node test, a webview with storage disabled), +reads and writes use an in-memory `Map` for the lifetime of that process. The check +still has something to compare against, instead of treating every call as first contact. +That map dies with the process. It is not a second store a browser user can inspect. + +Clearing site data deletes the `localStorage` key. The in-memory map is irrelevant in a +normal browser session because `localStorage` is present, so the next check after a +clear is `first-contact` and the server's current keys are pinned with no prompt. + +## When `IdentityKeyChangedError` is raised + +`checkIdentityChange` does not throw. It returns `status: 'changed'`. + +`IdentityKeyChangedError` is thrown by `assertDevicesTrusted` in +[`apps/web/src/lib/crypto.ts`](../src/lib/crypto.ts). That function groups the device +list by `userId`, calls `checkIdentityChange` for each user, and throws as soon as one +user returns `changed`. The error carries `userId` and `changedDeviceIds`. Its message +is `Identity key changed for user ${userId} — refusing to encrypt until the new safety number is verified`. The throw refuses the whole batch: no device in that call is +encrypted to. `first-contact` and `trusted` do not throw. A first-contact call has +already pinned inside `checkIdentityChange` before `assertDevicesTrusted` returns. + +Call sites of `assertDevicesTrusted`: + +| Call site | On the live send path? | +| --- | --- | +| `buildEnvelopes` in `crypto.ts` | Yes. `sendEncryptedMessage` and `sendEncryptedFile` (`apps/web/src/lib/fileEncryption.ts`) both go through `buildEnvelopes`. | +| `Phase1SessionCrypto.encryptToDevice` in `session.ts` | Only if something calls `defaultSession.encryptToDevice`. `buildEnvelopes` on the Phase-1 class delegates to `crypto.ts` `buildEnvelopes`, which checks again. | +| `LibsignalSessionCrypto.encryptToDevice` and `buildEnvelopes` | No. `defaultSession` is `new Phase1SessionCrypto()`. This class is not the live default. | + +The conversation page calls `sendEncryptedMessage` from `handleSendEncrypted`. A thrown +`IdentityKeyChangedError` lands in that function's `catch`, which calls `console.error` +and then clears the sending flag. The text stays in the input because `setSendText('')` +runs only after a successful send. The page does not import `IdentityKeyChangedError` +and does not open the safety panel because of it. + +## What `onSessionReset` subscribers do + +`onSessionReset(handler)` registers `handler(deviceIds: string[])` and returns an +unregister function. `checkIdentityChange` calls every handler when the status is +`changed`, and only then. It does not call them on first contact or on a match. + +The only registration outside tests is the module-level call in +[`apps/web/src/lib/signalSession.ts`](../src/lib/signalSession.ts). The handler deletes +the in-memory `DeviceSession` for each of those device ids, so a later +`ensureSession` on the Signal path would run X3DH again instead of reusing a session +for the old key. That module is imported by `signalClient.ts`, which +`LibsignalSessionCrypto` loads with a dynamic import. The live `Phase1SessionCrypto` +path does not import it, so this subscriber is not registered during a normal send. +Phase-1 encryption does not keep a ratchet session to tear down. The pin still blocks +the send, because `assertDevicesTrusted` throws whether or not a subscriber ran. + +## Safety number + +The panel does not compute a fingerprint. For each contact user id it +`GET`s `/users/${userId}/key-fingerprint` with the bearer token and renders the +`formatted` field. + +The server builds that value from the user's active (non-revoked) device +`identityPublicKey`s: sort the base64 strings, join with `\n`, SHA-256, then two +30-digit decimal segments from non-overlapping 15-byte halves of the digest, modulo +10^30. `fingerprint` is the 60 digits. `formatted` is the same digits in 12 groups of +5, space-separated. Revoked devices are excluded. The full derivation, including the +error statuses, is [GET /users/:id/key-fingerprint](../../backend/docs/api-users.md#get-usersidkey-fingerprint). +The client does not recompute it, so the digits on screen are whatever that response +contained. + +The page stores a verification record separately from the trust pin: + +- Key: `clicked.safetyVerification.${userId}` +- Value: `{ fingerprint, verifiedAt }` where `fingerprint` is the raw 60-digit string + and `verifiedAt` is an ISO timestamp +- `readVerification` returns `null` when `window` is missing, the key is absent, or the + JSON is the wrong shape. There is no in-memory fallback for this key. + +`changed` is true only when a stored verification exists and its `fingerprint` differs +from the response. No stored verification means `changed` is false, including the first +time the number is shown. + +### How two users compare them + +The number identifies one user's active device identity keys. It is not a pairwise +combination of both users' keys, so the two people in a DM are not shown one shared +number. + +The panel lists conversation members other than the signed-in user +(`contacts` in the page). In a DM, user A sees user B's formatted number, and user B +sees user A's. Comparing the two screens against each other compares different users +and will not match even when both responses are honest. + +A real comparison is: the 60-digit `fingerprint` (ignore spaces) shown for user B must +match the number computed from B's active identity keys. A can read B's number in this +panel. B's own number is not rendered on B's conversation page, so B cannot confirm it +from this screen. Out of band, they compare the groups of five for the same user id. + +**Mark verified** (`verifyContact`) writes `clicked.safetyVerification.${userId}` for +the fingerprint currently on screen and clears `changed` in React state. It does not +call `trustDevices`. Dismissing the warning does not move the encryption pin. + +### What the user is shown + +| State | Header button | Banner | Card | +| --- | --- | --- | --- | +| Loading | "Safety numbers" (or the changed label if some other contact is already changed) | none for this card | `Loading safety number...` | +| Fetch failed | unchanged by the error | none | `Safety number unavailable` (or the `Error.message`) | +| Number loaded, no stored verification or the stored fingerprint matches | "Safety numbers" | none | the `formatted` digits, button label "Mark verified" | +| Stored fingerprint differs from the response | "Safety number changed" (amber, warning icon). The panel is opened. | "Safety number changed, tap to re-verify." | same sentence, button "Mark verified" | +| `verifiedAt` set and not `changed` | "Safety numbers" | none | a check icon, button label "Verified" | + +The same banner and open panel are also set when a socket event arrives whose payload +names a contact in this conversation. The event names listened for are `key_set_changed`, +`key_changed`, `contact_key_changed`, `safety_number_changed`, and `system_event`. The +handler also calls `clearSessionKeys()` and `sessionStore.clear()`. That path is +independent of `checkIdentityChange`. An `IdentityKeyChangedError` from Send does not +switch the button or the banner. The user sees the composer keep their text and the +sending state end. The failure is `console.error` only. + +## Limits + +- TOFU trusts the first device set seen. `checkIdentityChange` pins it and returns + `first-contact` with no prompt and no safety-number comparison. +- The pin moves only when something calls `trustDevices`. The safety panel's + **Mark verified** button is not that call. After a real key change, Send keeps + throwing `IdentityKeyChangedError` until some other caller re-pins, even if the user + has marked the new number verified. +- Clearing browser storage deletes `clicked.identityTrust.*` and + `clicked.safetyVerification.*`. The next encrypt is first contact and pins the keys + the server returns. The next panel load has no stored fingerprint, so `changed` is + false. Nothing tells the user that trust was reset. +- A corrupt trust-pin value parses as `null`, which is the same as first contact: the + live set is pinned silently. +- The safety number is fetched from the server and displayed. This page does not + recompute it from the device list, so it does not by itself detect a server that + lies about the fingerprint. diff --git a/apps/web/docs/concepts-mls-client.md b/apps/web/docs/concepts-mls-client.md new file mode 100644 index 00000000..026334be --- /dev/null +++ b/apps/web/docs/concepts-mls-client.md @@ -0,0 +1,140 @@ +# Concepts: MLS client integration + +How the web client creates an MLS group, adds a member, and joins with a Welcome. +This page replaces `apps/web/src/lib/mls-integration.md`. Server storage, key-package +claiming, and epoch membership are not repeated here. + +- Key packages: [MLS key packages](../../backend/docs/mls-key-packages.md) +- Group membership and new-device join: [MLS group membership](../../backend/docs/mls-group-membership.md) + +> **Stale when:** a caller persists `MlsClient.exportState()`, or +> `apps/web/src/lib/crypto/mls.ts` is wired to `MlsClient`. Update the persistence +> section and the "which module to call" section in that change. + +## Which module feature code should call + +The two modules are not connected. `lib/crypto/mls.ts` does not import `lib/mls.ts`. +Nothing else in `apps/web/src` calls either one yet. Feature code still has a split +to follow: + +| Module | Responsibility | Call it for | +| --- | --- | --- | +| [`apps/web/src/lib/crypto/mls.ts`](../src/lib/crypto/mls.ts) | Client group coordination behind an injected `MlsProvider`. No network, no WASM import. Returns group state and an opaque Welcome. | Creating a group, adding members, joining from Welcome bytes, and building the Welcome payload the existing message pipeline can carry (`toMlsWelcomeMessage`). | +| [`apps/web/src/lib/mls.ts`](../src/lib/mls.ts) | Browser-only adapter over `@openmls/wasm`. Knows the ciphersuite and the WASM method names. Does not know backend routes. | Constructing the WASM client, `encrypt` / `decrypt`, `applyCommit`, and `exportState` / restore. A `MlsProvider` implementation would wrap this class. None is written yet. `MlsClient`'s methods are not the same shape as `MlsProvider`, so it is not a drop-in provider. | + +`MlsClient.create` throws `MLS is only available in a browser context` when +`window` is undefined, and throws if the `@openmls/wasm` constructor or a required +method is missing. Do not import it from a Server Component or a route handler. + +## Protocol and ciphersuite + +- Protocol: Messaging Layer Security (MLS), specified by [RFC 9420](https://www.rfc-editor.org/rfc/rfc9420). +- Selected ciphersuite: `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`, the + `MLS_CIPHERSUITE` constant in `apps/web/src/lib/mls.ts`. +- The ciphersuite provides 128-bit symmetric security, X25519 HPKE key agreement, + AES-128-GCM authenticated encryption, SHA-256 hashing, and Ed25519 signatures. +- Group state transitions, epochs, ratchets, credentials, and private keys are owned + by the browser-side OpenMLS WASM client. + +The application uses OpenMLS for the MLS wire protocol and does not implement MLS +cryptography itself. RFC 9420 conformance is therefore dependent on the selected +OpenMLS release and its upstream test suite. Application-level interoperability +testing against another RFC 9420 implementation remains required before describing +the product as fully interoperable. + +## Creating a group, adding a member, joining + +`createMlsGroup`, `addMlsMembers`, and `joinMlsGroup` only talk to the `MlsProvider` +the caller passes in. They copy bytes out so the caller cannot alias the provider's +buffers. `addMlsMembers` throws `At least one key package is required` on an empty +list. + +Key-package bytes are public material the backend stores and hands out. How a package +is published, deduplicated, and claimed is +[MLS key packages](../../backend/docs/mls-key-packages.md). This client treats a +`KeyPackage` as `{ deviceId, userId, keyPackage: Uint8Array }`. + +```mermaid +sequenceDiagram + participant Creator as Creating client + participant Provider as MlsProvider + participant Relay as Backend relay + participant Joiner as Joining client + + Creator->>Provider: createMlsGroup(creator KeyPackage) + Provider-->>Creator: MlsGroupState (kept locally) + + Creator->>Relay: fetch the new member's key package + Note over Relay: claim rules are in mls-key-packages.md + Creator->>Provider: addMlsMembers(state, key packages) + Provider-->>Creator: post-commit state plus Welcome + Creator->>Relay: toMlsWelcomeMessage (opaque Welcome only) + + Relay->>Joiner: Welcome for that device + Note over Relay: offline delivery is in mls-group-membership.md + Joiner->>Provider: joinMlsGroup(welcome bytes) + Provider-->>Joiner: MlsGroupState at the joined epoch +``` + +`toMlsWelcomeMessage` is the client transport shape for that Welcome: + +| Field | Value | +| --- | --- | +| `contentType` | `application/mls-welcome` (`MLS_WELCOME_CONTENT_TYPE`) | +| `ciphertext` | base64 of `welcome.ciphertext` | +| `epoch` | the epoch `addMlsMembers` returned | + +`MLS_WELCOME_EVENT` is the string `mls_welcome`. The helper does not open a socket +and does not POST. The caller puts that object on the existing message pipeline. + +What the relay stores, which epochs a new device can read, and why history from +before the join epoch is unavailable are +[MLS group membership](../../backend/docs/mls-group-membership.md). + +The state returned by `addMlsMembers` is the post-commit state. The function's +comment says the creating client is the one that must retain it. `joinMlsGroup` +returns the joiner's own state from `processWelcome`. Neither function writes +those objects anywhere. + +## Where group state is persisted + +It is not. `MlsClient` holds the OpenMLS instance in memory. `exportState()` returns +opaque bytes (`exportState`, `export_state`, or `serialize` on the binding). +`MlsClient.create(groupId, { credential, state })` passes `options.state` into the +WASM constructor when a caller has those bytes. No module writes the bytes to +`localStorage` or IndexedDB. [IndexedDB schemas](./contracts-indexeddb-schemas.md) +has no MLS store. Grep of `apps/web/src` finds no caller of `MlsClient` or +`exportState` outside `mls.ts` itself. + +Reloading the page drops the WASM instance. Clearing site data does not delete a +blob the app never stored, and it also does not restore one. After either, the +browser has no epoch secrets for that group. + +Lost with the instance: + +- opaque group state, epoch secrets, and ratchet secrets +- MLS signing private keys held inside the WASM client +- the ability to `encrypt`, `decrypt`, or `applyCommit` for that group until this + device processes a new Welcome and keeps the resulting state + +Not lost, because they were never in that blob: ciphertext and public group state +the backend already stored, and key packages already uploaded. Those stay subject +to the membership rules in the backend doc. A cleared browser does not tell the +server that this device forgot the group. + +`MlsClient.exportState()` returns opaque state for local browser persistence only. +It must not be included in API requests, WebSocket messages, logs, analytics, or +crash reports. The transport boundary may carry only: + +- public credentials and key packages needed for onboarding; +- MLS Welcome, Proposal, and Commit messages; +- MLS ciphertext and authenticated data. + +The backend is an untrusted relay and never receives group state, epoch secrets, +ratchet secrets, signing private keys, plaintext, or exported client state. +Decryption occurs only after ciphertext reaches a client holding the corresponding +local group state. + +## Audit status + +No independent security audit of the exact `@openmls/wasm` artifact, its WebAssembly build, or this adapter has been completed by DripTide. Upstream maintenance and release history are not a substitute for an application-specific audit. The dependency version is explicitly declared in `apps/web/package.json` so upgrades can be reviewed and audited. diff --git a/apps/web/docs/crypto-module-map.md b/apps/web/docs/crypto-module-map.md index dce531f2..f56b15ca 100644 --- a/apps/web/docs/crypto-module-map.md +++ b/apps/web/docs/crypto-module-map.md @@ -38,7 +38,7 @@ Paths are relative to `apps/web/src/lib/`. | `prekeyStore.ts` | **implemented, not wired** | Generates, signs, stores and uploads signed and one-time prekeys. | `sessionStore.ts` (consumes one-time prekeys) | | `crypto/doubleRatchet.ts` | **implemented, not wired** | Pure Double Ratchet primitives with skipped-message-key support (`MAX_SKIP = 100`, `MAX_SKIPPED_KEYS = 1000`). | tests only | | `crypto/ratchetSession.ts` | **implemented, not wired** | IndexedDB-backed Double Ratchet session store (database `driptide-crypto`). Rejects out-of-order messages. | tests only | -| `mls.ts` | **implemented, not wired** | Browser-only wrapper around `@openmls/wasm` (see `mls-integration.md`). | none | +| `mls.ts` | **implemented, not wired** | Browser-only wrapper around `@openmls/wasm` (see [concepts-mls-client.md](./concepts-mls-client.md)). | none | | `crypto/mls.ts` | **implemented, not wired** | Client-side MLS group coordination (key packages, Welcome handling). Backend only sees opaque bytes. | none | `session.ts` imports from `crypto.ts`, and `sessionStore.ts` imports `prekeyStore.ts`, which imports `cryptoStore.ts`. Those internal imports do not make the importing module live. diff --git a/apps/web/src/lib/mls-integration.md b/apps/web/src/lib/mls-integration.md deleted file mode 100644 index 6d1ff305..00000000 --- a/apps/web/src/lib/mls-integration.md +++ /dev/null @@ -1,26 +0,0 @@ -# MLS integration - -The web client integrates the maintained [`@openmls/wasm`](https://www.npmjs.com/package/@openmls/wasm) binding through `src/lib/mls.ts`. The binding is loaded only in a browser context, so the MLS implementation and its private state are never initialized on the server. - -## Protocol and ciphersuite - -- Protocol: Messaging Layer Security (MLS), specified by [RFC 9420](https://www.rfc-editor.org/rfc/rfc9420). -- Selected ciphersuite: `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`. -- The ciphersuite provides 128-bit symmetric security, X25519 HPKE key agreement, AES-128-GCM authenticated encryption, SHA-256 hashing, and Ed25519 signatures. -- Group state transitions, epochs, ratchets, credentials, and private keys are owned by the browser-side OpenMLS WASM client. - -The application uses OpenMLS for the MLS wire protocol and does not implement MLS cryptography itself. RFC 9420 conformance is therefore dependent on the selected OpenMLS release and its upstream test suite. Application-level interoperability testing against another RFC 9420 implementation remains required before describing the product as fully interoperable. - -## Audit status - -No independent security audit of the exact `@openmls/wasm` artifact, its WebAssembly build, or this adapter has been completed by DripTide. Upstream maintenance and release history are not a substitute for an application-specific audit. The dependency version is explicitly declared in `apps/web/package.json` so upgrades can be reviewed and audited. - -## State and backend boundary - -`MlsClient.exportState()` returns opaque state for local browser persistence only. It must not be included in API requests, WebSocket messages, logs, analytics, or crash reports. The transport boundary may carry only: - -- public credentials and key packages needed for onboarding; -- MLS Welcome, Proposal, and Commit messages; -- MLS ciphertext and authenticated data. - -The backend is an untrusted relay and never receives group state, epoch secrets, ratchet secrets, signing private keys, plaintext, or exported client state. Decryption occurs only after ciphertext reaches a client holding the corresponding local group state. diff --git a/docs/README.md b/docs/README.md index b2f8d944..7aede90d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -115,6 +115,9 @@ is about encryption and local state. | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | [Web app README](../apps/web/README.md) | Running the Next.js client on its own, its scripts, and its environment variables. | | [E2EE architecture](../apps/web/docs/concepts-e2ee-architecture.md) | Where keys live in the browser, how sessions are established, and what never leaves the device. | +| [Identity trust, TOFU, and safety numbers](../apps/web/docs/concepts-identity-trust.md) | The client trust pin, when `IdentityKeyChangedError` is thrown, and how the safety-number panel is computed and shown. | +| [MLS client integration](../apps/web/docs/concepts-mls-client.md) | How `lib/mls.ts` and `lib/crypto/mls.ts` split group creation, Welcome join, and browser group state. | +| [Landing page components](../apps/web/docs/components-landing.md) | Where landing copy and links live, which public claims overstate what is shipped, and the breakpoints that folder uses. | | [Message pipeline](../apps/web/docs/concepts-message-pipeline.md) | The client-side path from typed text to an encrypted envelope on the wire, and back. | | [Auth and device lifecycle](../apps/web/docs/concepts-auth-device-lifecycle.md) | Wallet connection, device registration, session persistence, and revocation handling. | | [File encryption](../apps/web/docs/concepts-file-encryption.md) | How attachments are encrypted client-side before upload. | @@ -137,7 +140,7 @@ is about encryption and local state. | [Auth session contract](../apps/web/docs/contracts-auth-session.md) | The shape of the persisted session and what invalidates it. | | [IndexedDB schemas](../apps/web/docs/contracts-indexeddb-schemas.md) | Every IndexedDB object store, its keys, and its migration history. | | [Response types](../apps/web/docs/contracts-response-types.md) | Shared TypeScript response types used across the client. | -| [MLS integration notes](../apps/web/src/lib/mls-integration.md) | Implementation notes co-located with the MLS integration code. | +| [MLS client integration](../apps/web/docs/concepts-mls-client.md) | Client-side MLS group creation, Welcome join, and where browser group state lives. | | [Search module README](../apps/web/src/lib/search/README.md) | Implementation notes for the local search module. | ### Contract developer @@ -185,6 +188,8 @@ Threat model, hardening, and the crypto protocol documents. | [Signal integration](signal-integration.md) | How the Signal protocol is integrated and which guarantees it provides. | | [Group epoch sync](group-epoch-sync.md) | How MLS group epochs stay synchronised across devices and what happens when they diverge. | | [E2EE architecture](../apps/web/docs/concepts-e2ee-architecture.md) | The client-side key model — the basis for any claim that the server cannot read messages. | +| [Identity trust and safety numbers](../apps/web/docs/concepts-identity-trust.md) | The TOFU pin, `IdentityKeyChangedError`, and the limits of the safety-number panel. | +| [MLS client integration](../apps/web/docs/concepts-mls-client.md) | Browser MLS client boundary, Welcome transport, and what is lost if group state is cleared. | ### AI / data developer @@ -211,6 +216,7 @@ Documents about the repository itself rather than about the product. | Document | What it gives you | | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | | [Security policy](../SECURITY.md) | How and where to report a vulnerability privately, and why never in a public issue or pull request. | +| [Documentation style guide](documentation-style-guide.md) | Where docs live, the file-name prefixes, and the checklist for reviewing a docs PR. | | [Testing strategy and conventions](testing.md) | Cross-app testing philosophy, runners, and the conventions a contributor must follow. | | [Pull request template](../.github/pull_request_template.md) | The checklist every pull request is opened against. | | [PR notes](../pr.md) | Scratch notes for an in-flight pull request; not a reference document. | diff --git a/docs/adr/0006-mls-for-groups.md b/docs/adr/0006-mls-for-groups.md index 9331c8eb..5efc2f76 100644 --- a/docs/adr/0006-mls-for-groups.md +++ b/docs/adr/0006-mls-for-groups.md @@ -11,7 +11,7 @@ members can open, without giving the server the key. The chosen protocol is MLS (RFC 9420) via `@openmls/wasm` on the client; the backend stores public group state only ([mls-group-membership.md](../../apps/backend/docs/mls-group-membership.md), -[mls-integration.md](../../apps/web/src/lib/mls-integration.md)). +[concepts-mls-client.md](../../apps/web/docs/concepts-mls-client.md)). ## Decision diff --git a/docs/documentation-style-guide.md b/docs/documentation-style-guide.md new file mode 100644 index 00000000..74541911 --- /dev/null +++ b/docs/documentation-style-guide.md @@ -0,0 +1,110 @@ +# Documentation style guide + +Conventions the documentation batches have been following, so the next doc matches the +ones already in the tree. This file is the source for those conventions. It does not +restate product behaviour. + +## File placement and naming + +Put a document in the `docs/` folder of the app it describes. Those folders are flat: +one file, no subdirectories. + +| Folder | What goes there | +| --- | --- | +| `apps/web/docs/` | The Next.js client | +| `apps/backend/docs/` | The Express gateway | +| `apps/ai_agent/docs/` | The FastAPI service | +| `contracts/docs/` | The Soroban contracts | +| `docs/` | Cross-cutting material: architecture, threat model, runbooks, ADRs, user guides | + +Root `docs/` is the exception that has subfolders, and only for material that is not +owned by one app: `adr/`, `security/`, `user-guide/`, `flows/`. Do not add a new +subdirectory inside an app `docs/` folder. If a document is only about one app, it does +not belong in root `docs/`. + +File names are kebab-case. Prefer one of these prefixes. The prefix is the kind of +document, not a topic tag. + +| Prefix | Use it for | Examples already in the tree | +| --- | --- | --- | +| `api-` | A callable surface: routes, client wrappers, contract entry points. Request, response, and errors. | `apps/backend/docs/api-auth.md`, `apps/web/docs/api-rest-client.md`, `contracts/docs/api-proposals.md` | +| `contracts-` | A shape other code depends on: a schema, an error catalog, a stored record, a serialised payload. | `apps/backend/docs/contracts-jwt-auth.md`, `apps/web/docs/contracts-indexeddb-schemas.md` | +| `concepts-` | How a subsystem works in the current code: who calls whom, what is persisted, what the limits are. | `apps/web/docs/concepts-message-pipeline.md`, `apps/backend/docs/concepts-delivery-fanout.md` | +| `howto-` | A task recipe a contributor follows to add something, with one worked example taken from code that exists. | `apps/backend/docs/howto-add-rest-endpoint.md`, `apps/backend/docs/howto-add-socket-event.md` | +| `tutorial-` | A walkthrough that starts from a clean checkout and ends at a running result. | `contracts/docs/tutorial-testnet-treasury.md` | + +Two other names are already established. Use them instead of inventing a new prefix: +`components-` for a UI component reference (`apps/web/docs/components-ui.md`), and a bare +name for a single app-wide guide that is none of the five (`testing.md`, `styling.md`, +`accessibility.md`). A new bare name needs a reason the five prefixes do not fit. + +When a document is added, moved, or removed, update its row in +[`docs/README.md`](README.md) in the same change. That index is how the file gets found. + +## Write against the current code + +Describe what the code does on the branch you are editing. Read the file you name. +`IMPLEMENTATION_DOCS.md` is not the source of truth. It records intent and it drifts. If +it disagrees with the code, the doc follows the code. + +State a limitation in the same place as the behaviour it limits. A phase, a feature flag, +or a one-line swap that is not the live assignment is not shipped. Name the live symbol. +Example: message encryption's live default is `Phase1SessionCrypto` assigned to +`defaultSession` in `apps/web/src/lib/session.ts`. `LibsignalSessionCrypto` is in that +file and is not the default. A doc that says the client uses the Signal protocol is +describing intended behaviour. + +Do not paste an endpoint table, a storage layout, or a derivation that already has a +canonical page. Link to that page. Repeat a fact only when the reader cannot follow the +link and still do the task, and then keep the repeat to a sentence. + +## Diagrams, code references, and cross-links + +Use a [Mermaid](https://mermaid.js.org/) fence for a sequence or a flow. One diagram per +flow. Participant names should be the module or the process a reader can find in the +repo (`MlsClient`, `Backend`), not "the system". + +```mermaid +sequenceDiagram + participant Caller + participant Module + Caller->>Module: call the function that exists + Module-->>Caller: what that function returns +``` + +Refer to code with a repo-relative path, and a symbol when the path is not enough: +`apps/web/src/lib/identityTrust.ts` `checkIdentityChange`. Markdown links to other docs +are relative (`../backend/docs/mls-key-packages.md` from a web doc, `./api-users.md` +from a sibling). Do not link to a machine path, a branch URL, or a line number that will +rot on the next edit. A GitHub issue link is fine when the doc exists because of that +issue. + +## Marking a doc that a known change will stale + +When you know a specific change will make a section wrong, put this quote under the +heading it applies to, naming the change in a way a later editor can grep: + +> **Stale when:** `defaultSession` in `apps/web/src/lib/session.ts` is switched from +> `Phase1SessionCrypto` to `LibsignalSessionCrypto`. Replace the Phase-1 description in +> this section in that change. + +Rules for the marker: + +- Name the file and the symbol that will change, not a vague "when Signal ships". +- The sentence after the condition says what to edit. +- Delete the marker in the change that makes it true. Leaving it after the change is + the same as the section being wrong. +- Do not use the marker as a TODO list for work nobody has scheduled. If the code is + already different from the doc, fix the doc. + +## Checklist for reviewing a docs PR + +- [ ] The file is in the right `docs/` folder, flat, kebab-case, and uses a prefix from the table above. +- [ ] [`docs/README.md`](README.md) has a row for every added file and no row for a deleted one. +- [ ] Behaviour matches the current code. The review can point at the file named in the doc. +- [ ] Limitations are stated next to the behaviour. Intended phases are labelled as not live. +- [ ] `IMPLEMENTATION_DOCS.md` was not used as the source. +- [ ] Server behaviour, schemas, and algorithms that already have a page are linked, not copied. +- [ ] Paths are repo-relative. Mermaid fences parse. +- [ ] A section that a known change will invalidate has a **Stale when** quote, and the quote names the change. +- [ ] A doc that replaces another one deletes the old file and updates every inbound link in the same PR.