Skip to content
Open
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
7 changes: 7 additions & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,13 @@
"xchat/troubleshooting"
]
},
{
"group": "Security practices",
"pages": [
"xchat/building-ui-apps-with-wasm",
"xchat/handling-private-keys"
]
},
{
"group": "API reference",
"pages": [
Expand Down
179 changes: 179 additions & 0 deletions xchat/building-ui-apps-with-wasm.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
---
title: "Build X Chat UI apps with WASM so private keys never leave the browser"
sidebarTitle: Building UI apps with WASM
description: "Run the Chat XDK in the browser with WebAssembly so encryption stays on-device, private keys never hit your server, and users only send ciphertext through the X API."
keywords: ["X Chat WASM", "Chat XDK browser", "client-side encryption", "WebAssembly", "UI chat app", "private keys browser", "Juicebox PIN"]
---

For **user-facing chat UIs**, run the [Chat XDK](/xchat/xchat-xdk) **in the browser** via its JavaScript/WASM package (`@xdevplatform/chat-xdk`). Private identity and signing keys stay on the user's device. Your servers (and X) only ever see **ciphertext**, public keys, and OAuth tokens—not the PIN or private key material used to encrypt and sign messages.

This page is the recommended architecture for client apps. For PIN and key-handling rules that apply to every app type, see [Handling private keys](/xchat/handling-private-keys).

---

## Why WASM for UI apps

| Approach | Where crypto runs | Private keys | Fit |
|:---------|:------------------|:-------------|:----|
| **WASM in the browser** (`createChat`) | User's device | Recovered with PIN into WASM memory; not sent to your backend | **Recommended for chat UIs** |
| **Native Chat XDK on a server** | Your servers | Key blob or passcode-backed recovery on the server | Bots and automation—not end-user chat clients |

End users should **never** paste their encryption PIN into your backend, and your backend should **never** hold their identity private keys. If a third-party server receives a user's PIN or root private keys, that party can decrypt conversation keys wrapped to that identity **even after the user revokes OAuth access**. Client-side WASM avoids that class of failure for legitimate apps.

<Warning>
Sharing a PIN or private key with a third party is like sharing a password for encrypted DMs. OAuth disconnect does **not** revoke keys the app already obtained. Prefer WASM so keys never leave the browser; document risks clearly when you cannot. See [Handling private keys](/xchat/handling-private-keys).
</Warning>

---

## Recommended architecture

Split **crypto** (browser) from **API transport** (your backend or direct X API with a user token):

```mermaid
flowchart TB
subgraph Browser
UI[Chat UI]
WASM[Chat XDK WASM<br/>encrypt / decrypt / sign]
Keys[Private keys in memory<br/>after PIN unlock]
UI --> WASM
WASM --> Keys
end

subgraph Your backend optional
API[API routes<br/>OAuth user token]
end

X[X API<br/>ciphertext only]

UI -->|encrypted payloads| API
API --> X
UI -->|or user access token| X
```

| Layer | Responsibility |
|:------|:---------------|
| **Browser UI** | Render conversations; collect PIN **only in the client**; call Chat XDK WASM for encrypt/decrypt/sign |
| **Chat XDK WASM** | Key generation, secure key backup (`setup` / `unlock`), message crypto |
| **Your backend (optional)** | Hold the OAuth access token, proxy X Chat REST, mint Juicebox realm auth tokens—**never** receive PIN or private keys |
| **X API** | Public keys, wrapped conversation keys, encrypted events and media |

A common pattern (used by internal demos such as browser chat clients) is: **WASM + React (or similar) in the frontend**, **TypeScript [XDK](/xdks/typescript/overview) in Next.js (or other) API routes** so the browser never talks to `api.x.com` with a long-lived secret if you prefer not to. Crypto still runs only in the browser.

---

## Install the browser package

```bash
npm install @xdevplatform/chat-xdk
npm install juicebox-sdk # required for setup() / unlock() secure key backup
```

The compiled WASM engine ships inside `@xdevplatform/chat-xdk`; there is no separate Rust toolchain for consumers. Requires a modern browser (and Node.js 18+ if you share code with SSR—run crypto only on the client).

---

## Session flow (PIN once, keys stay in memory)

Do **not** prompt for the PIN on every message. Unlock **once per browser session**, keep the `Chat` instance in memory (module singleton, React context, etc.), then encrypt and decrypt against that unlocked instance.

```typescript
import { createChat } from '@xdevplatform/chat-xdk';

// 1) Create once per page load (client component / browser only)
const chat = await createChat({
juiceboxConfig: JSON.stringify(record.juicebox_config), // from GET public keys for the user
getAuthToken: async (realmId) => {
// Your backend mints a Juicebox realm token for this user + key version.
// Do not send the user's PIN here—only realm auth for secure key backup.
const res = await fetch(`/api/juicebox/token?realm=${encodeURIComponent(realmId)}`);
if (!res.ok) throw new Error('Juicebox token fetch failed');
return res.text();
},
});

// 2) First-time identity: generate → register public keys with X → backup with PIN
// const payload = chat.generateKeypairs();
// await registerPublicKeysWithX(payload); // POST /2/users/:id/public_keys
// await chat.setup(pin); // PIN never leaves the browser

// 3) Returning session: recover keys with PIN (once)
await chat.unlock(pin);
chat.setIdentity(userId, signingKeyVersion);
chat.setCacheKeys(true);
// Fetch participants' public keys from X, then:
chat.setSigningKeys(signingKeys);

// 4) Use for the whole SPA session—no more PIN prompts
const result = chat.decryptEvents(rawEvents);
const sendBody = chat.encryptMessage({ conversationId, text: 'Hello' });
// POST sendBody to your backend or X Chat send-message endpoint

// 5) On logout or "lock chat"
chat.lock(); // clears key material from the WASM instance
// chat.free(); // if you will not reuse this instance
```

### UX expectations

| Event | What to do |
|:------|:-----------|
| **App open / hard reload** | User enters PIN → `unlock` → keep instance for navigation within the SPA |
| **Send / receive / media** | Call encrypt/decrypt on the **already unlocked** instance |
| **Logout / switch account** | `lock()` or `free()`; drop references; clear any session state |
| **Forgot PIN / lockout** | Secure key backup enforces a guess limit; recovery may require key reset (new keypairs + re-register). See [Cryptography primer](/xchat/cryptography-primer#secure-key-backup-distributed-key-storage) |

<Tip>
**Avoid re-prompting the PIN on every action.** Demo apps sometimes call `unlock` repeatedly for simplicity. Production UIs should unlock once, hold the instance in memory, and only ask for the PIN again after reload, logout, or `lock()`.
</Tip>

---

## What your server is allowed to see

| Allowed on the server | Never send to the server |
|:----------------------|:-------------------------|
| OAuth 2.0 user access token | Encryption PIN / passcode |
| Juicebox **realm auth tokens** (short-lived, for backup protocol) | Identity or signing **private** keys |
| Public key registration payloads | Raw `export_keys` blobs for end-user identities (browser apps) |
| Encrypted message and media payloads | Plaintext message bodies (unless the user is composing them only in the UI) |
| Conversation ids, event ids, metadata X already stores | Anything that reconstructs the user's root key material |

Realm tokens for Juicebox are **not** the user's PIN. They authorize the backup protocol for that user and key version. Keep minting them on a backend that already holds the user OAuth context.

---

## Secure key backup in the browser

Client apps should use **secure key backup** (`setup` / `unlock` with a passcode), not a raw key file:

1. Load `juicebox_config` from the user's public-key record (`public_key.fields=juicebox_config`).
2. `createChat({ juiceboxConfig, getAuthToken })`.
3. First time: `generateKeypairs` → register public keys with X → `setup(pin)`.
4. Later: `unlock(pin)` on this device (or a new device with the same PIN).

The Chat XDK's browser path recovers keys into WASM and **does not expose raw private-key export on the public `createChat` surface**, so application JavaScript is not encouraged to pull root key bytes into the page. Prefer that model over hand-rolled `localStorage` key dumps.

Full registration and unlock steps: [Getting Started](/xchat/getting-started). Concepts: [Cryptography primer](/xchat/cryptography-primer).

---

## Browser hardening checklist

- Run Chat XDK only in **client** bundles (no SSR of unlocked keys).
- Treat the unlocked `Chat` instance like a live session secret: do not put it on `window`, do not log it, do not post it to analytics.
- Defend against **XSS**: CSP, careful `dangerouslySetInnerHTML` / markdown rendering, dependency hygiene. XSS in a chat app can reach keys in memory even when keys never hit the network.
- Use **HTTPS** everywhere; never mix crypto pages with insecure scripts.
- Prefer **minimal OAuth scopes**; request DM scopes only when needed and explain them in your product UI.
- On logout, call **`lock()`** / **`free()`** and discard the instance.

Storage recommendations (what not to put in `localStorage`, how to think about session persistence) are in [Handling private keys](/xchat/handling-private-keys#browser-session-persistence).

---

## Next steps

1. [Handling private keys](/xchat/handling-private-keys) — PIN warnings, storage, bots vs UI apps
2. [Getting Started](/xchat/getting-started) — full key registration and first message
3. [Chat XDK](/xchat/xchat-xdk) — API reference for `createChat`, encrypt, decrypt
4. [Real-time events](/xchat/real-time-events) — deliver ciphertext to the client for local decrypt
160 changes: 160 additions & 0 deletions xchat/handling-private-keys.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---
title: "Best practices for handling X Chat private keys and PINs"
sidebarTitle: Handling private keys
description: "Treat encryption PINs and private keys like root credentials: never share them with third parties, never put them on your servers for UI apps, and store them only with secure key backup or a hardened secret store."
keywords: ["X Chat private keys", "PIN security", "key management", "secure key backup", "Juicebox", "key storage", "OAuth vs PIN", "chat security"]
---

X Chat identity and signing **private keys** are the root of a user's encrypted messaging identity. Anyone who holds them can unwrap conversation keys delivered to that identity and sign as that user for chat. Treat the encryption **PIN / passcode** the same way: it recovers those keys from secure key backup.

This page covers rules for every app type. For the recommended client architecture, see [Building UI apps with WASM](/xchat/building-ui-apps-with-wasm).

---

## Critical warning for users and app builders

<Warning>
**Never ask end users to share their encryption PIN or private keys with a third-party server, support agent, or “helper” app.**

The PIN unlocks **root identity keys**. A party that obtains the PIN (or the private key blob) can:

- Decrypt conversation keys wrapped to that identity (and therefore message history they can obtain as ciphertext)
- Continue to send and receive as that cryptographic identity
- Keep that ability **even if the user later revokes OAuth** for the app

Revoking OAuth stops API access for that app's tokens. It does **not** invalidate private keys the user already gave away. Prefer architectures where the PIN is entered only into **on-device** crypto ([WASM in the browser](/xchat/building-ui-apps-with-wasm) or a native client using the Chat XDK).
</Warning>

### Product and docs copy you should show

If your app requests DM-related OAuth scopes (`dm.read`, `dm.write`, and related scopes), pair the OAuth consent screen with clear product language:

- Encryption keys stay on the user's device when you use the official client SDK path.
- Users should never type their X Chat PIN into a website that forwards it to a backend.
- Legitimate integrations use the [Chat XDK](/xchat/xchat-xdk) so PIN-backed recovery and crypto run locally (for browsers: WASM + secure key backup).
- A malicious app that obtains private keys can exfiltrate them; there is no server-side “revoke this key” for a pure client-held root key today—design so users never need to hand root keys to you.

---

## What counts as sensitive key material

| Material | Sensitivity | Notes |
|:---------|:------------|:------|
| **Encryption PIN / passcode** | Critical | Recovers private keys from [secure key backup](/xchat/cryptography-primer#secure-key-backup-distributed-key-storage) |
| **Identity private key** | Critical | Unwraps conversation keys for this user |
| **Signing private key** | Critical | Proves authorship of messages and state changes |
| **`export_keys` blob** | Critical | Opaque full private-key state from the Chat XDK; treat like a password file |
| **Unwrapped conversation keys** | High | Decrypt messages in one conversation (versioned; still sensitive) |
| **OAuth access / refresh tokens** | High | API access only—not a substitute for chat private keys, and not revoked by deleting keys |
| **Juicebox realm auth tokens** | Medium | Authorize backup protocol for a user/key version; not the PIN |
| **Public keys** | Public | Safe to fetch and store |

---

## Choose the right key path by app type

| App type | Recommended key path | Do not |
|:---------|:---------------------|:-------|
| **User-facing web UI** | Browser [WASM Chat XDK](/xchat/building-ui-apps-with-wasm) + `setup` / `unlock` (secure key backup) | Send PIN or private keys to your servers; store raw key blobs in `localStorage` |
| **Native mobile / desktop client** | Chat XDK on device + secure key backup or OS keystore-backed blob | Sync unencrypted key blobs to your cloud |
| **Bot / automation on your infrastructure** | `export_keys` blob in a **secret manager** or HSM; load with `import_keys` | Commit blobs to git; log them; embed in client-side JS |
| **Server that only moves ciphertext** | No private keys at all—proxy encrypted payloads | Decrypt “for convenience” on the server for UI users |

Client apps should prefer **secure key backup**. Servers and bots often use an exported key blob. Details: [Getting Started](/xchat/getting-started#2-initialize-the-chat-xdk-with-existing-keys).

---

## Rules for private keys and PINs

### Do

- **Collect the PIN only in trusted client UI** that feeds the Chat XDK (`unlock` / `setup`) on the same device.
- **Keep keys in memory only while needed.** After unlock, reuse the same Chat instance for the session; call `lock()` or `free()` on logout.
- **Zeroize PIN buffers when the API allows** (for example pass a `Uint8Array` PIN in JS so you can clear it after `unlock`).
- **Store bot key blobs in a secret manager** (or HSM), encrypt at rest, restrict IAM, rotate process credentials often.
- **Log carefully:** never log PINs, private keys, key blobs, unwrapped conversation keys, or full secure-backup responses.
- **Defend the client:** XSS, malicious extensions, and compromised dependencies can read in-memory keys even when the network path is clean.

### Do not

- **Do not** email, screenshot, or ticket a PIN or key blob.
- **Do not** put private keys or PINs in query strings, analytics, error trackers, or CDN logs.
- **Do not** ship end-user private keys to “make the backend simpler.”
- **Do not** confuse **OAuth revoke** with **key revoke**. Disconnecting an app does not erase keys a user already exported or typed into a hostile client.
- **Do not** store raw `export_keys` output in `localStorage` or unencrypted IndexedDB for production UI apps.

---

## Browser session persistence

Production browser apps should optimize for **security first**, then UX:

| Strategy | Security | UX | Recommendation |
|:---------|:---------|:---|:---------------|
| **Unlock once per page load; hold Chat in memory** | Strong | PIN after every full reload; no PIN per message | **Default recommendation** |
| **Re-prompt PIN on every message** | Strong but noisy | Poor | Avoid (demo-only anti-pattern) |
| **Plaintext or base64 key blob in `localStorage`** | Weak (any XSS or shared device reads keys) | Convenient | **Do not use in production** |
| **PIN-gated secure key backup only** (Juicebox) | Strong; guess-limited recovery | PIN on new device / reload | **Preferred durable storage** |

Internal demos sometimes export keys to `localStorage` for convenience. That is fine for throwaway prototypes; it is **not** a production pattern. Prefer:

1. `createChat` + `unlock(pin)` after reload.
2. Module-level or framework context holding the unlocked instance for SPA navigation.
3. `lock()` when the tab logs out or the user locks the app.

If you add extra “stay unlocked on this device” behavior, wrap any persisted material with **Web Crypto** (non-extractable keys where possible), bind it to the user session, and still never upload that material to your servers. The durable recovery path remains the user's PIN and secure key backup—not a second copy of the root key on your infrastructure.

---

## OAuth scopes vs encryption keys

These are separate control planes:

```mermaid
flowchart LR
subgraph OAuth
T[Access token]
T --> API[X Chat HTTP API]
end

subgraph Crypto
PIN[User PIN]
PK[Private keys]
PIN --> PK
PK --> E[Encrypt / decrypt / sign]
end

API -->|ciphertext| E
```

- **OAuth** authorizes API calls (list conversations, post ciphertext, fetch events).
- **Private keys** authorize cryptographic access to message contents.

A complete product should:

1. Request only the DM scopes it needs.
2. Explain why DM access is required.
3. Run crypto **on device** so OAuth never becomes a channel for collecting PINs.
4. Stop holding tokens on logout; separately `lock()` chat keys.

---

## Operational checklist

- [ ] No PIN or private key fields on server request bodies for UI flows
- [ ] Secure key backup (`setup` / `unlock`) for clients; secret manager for bot blobs
- [ ] Unlocked Chat instance scoped to one user session; cleared on logout
- [ ] Logging and APM scrubbed of secrets
- [ ] CSP and XSS controls on any page that can unlock chat
- [ ] User-facing copy: never share PIN with third parties
- [ ] Incident plan: if a bot blob leaks, rotate keys / re-register and treat historical ciphertext as exposed to the holder of the old key

---

## Related reading

- [Building UI apps with WASM](/xchat/building-ui-apps-with-wasm) — client architecture
- [Cryptography primer](/xchat/cryptography-primer) — identity keys, conversation keys, secure key backup
- [Getting Started](/xchat/getting-started) — register keys and send a message
- [Chat XDK](/xchat/xchat-xdk) — API reference
- [Fundamentals: Security](/fundamentals/security) — OAuth and API credential hygiene
3 changes: 2 additions & 1 deletion xchat/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,5 @@ Use **OAuth 2.0 user context** with DM-related scopes (`dm.read`, `dm.write`, pl
1. [Cryptography primer](/xchat/cryptography-primer): optional background on encryption concepts
2. [Getting Started](/xchat/getting-started): implement keys, send, and receive
3. [Chat XDK](/xchat/xchat-xdk): encryption SDK reference
4. [Real-time events](/xchat/real-time-events), [Media](/xchat/media), or [Troubleshooting](/xchat/troubleshooting) when you need those topics
4. [Building UI apps with WASM](/xchat/building-ui-apps-with-wasm) and [Handling private keys](/xchat/handling-private-keys): keep keys on-device and treat PINs as root credentials
5. [Real-time events](/xchat/real-time-events), [Media](/xchat/media), or [Troubleshooting](/xchat/troubleshooting) when you need those topics