# Encrypted transport

Reports often carry sensitive data — user emails and ids, and "super sensitive"
code blocks. HTTPS protects that body from passive network sniffers, but it is
still fully readable in the **browser DevTools network tab**, at any
TLS-terminating proxy or CDN, and in access logs. BugBoard lets you **encrypt
the payload** on the client so the body is an opaque blob everywhere on the wire.

> **In transit, not zero-knowledge.** BugBoard decrypts the report on receipt
> to build the card, then dedupes, stores, and displays it as usual. Encryption
> protects the payload _on the wire and in your logs/proxies_ — it does not hide
> the data from BugBoard itself.

## How it works

Encryption uses a **libsodium sealed box** (`crypto_box_seal`, X25519) — anonymous
public-key encryption:

1. Each project can hold one or more **encryption keys** (an X25519 keypair),
   separate from your API keys.
2. The SDK encrypts each report with the project's **public key**. Because only
   the public key is needed to encrypt, it is **safe to embed in client code** —
   exactly like a publishable API key.
3. BugBoard opens the sealed box with the matching **private key**, which is
   stored encrypted at rest and never leaves the server.

A fresh ephemeral key is used per message, so the ciphertext leaks nothing
reusable. Encryption is **optional and auto-detected**: a project can mix
encrypted and plaintext clients, and the server handles each correctly.

## Generate an encryption key

Open your project → **Settings → API Keys → Payload encryption** (requires the
`manageApiKey` ability) and **Generate key**. You get:

- a **public key** (`base64`) — configure this in your SDK;
- a **key id** (`bbek_…`) — public; tells the server which key to decrypt with.

The public key can be copied any time (it is not a secret). Revoke a key to stop
it decrypting; revocation is immediate and per-key, so you can rotate without
breaking other integrations.

## Configure the SDK

> **JavaScript:** the sealed box isn't in Node's built-in `crypto` or the browser
> Web Crypto API, so the SDK ships a tiny tweetnacl sealed-box binding
> (`tweetnacl-sealedbox-js`) and lazy-loads it automatically the moment you set an
> encryption key — there's nothing extra to install or import.
>
> **PHP:** the sealed box comes from libsodium, exposed by PHP's `sodium`
> extension. It must be **installed and enabled on the host** — see
> [Enabling PHP's sodium extension](#enabling-phps-sodium-extension) below.

Set the encryption public key and every report is encrypted automatically:

<!-- tabs:JavaScript,PHP -->

```ts
import { createClient } from 'bugboard';

const bugboard = createClient({
    // ...
    encryptionKeyId: import.meta.env.VITE_BUGBOARD_ENCRYPTION_KEY_ID,
    encryptionPublicKey: import.meta.env.VITE_BUGBOARD_ENCRYPTION_PUBLIC_KEY,
});
```

```php
use BugBoard\ClientBuilder;
use BugBoard\Config;

$bugboard = ClientBuilder::create(new Config(
    // ...
    encryptionKeyId: getenv('BUGBOARD_ENCRYPTION_KEY_ID'),
    encryptionPublicKey: getenv('BUGBOARD_ENCRYPTION_PUBLIC_KEY'),
));
```

## Enabling PHP's sodium extension

Sealed boxes come from **libsodium**, which PHP exposes through its `sodium`
extension. The extension has been _bundled_ with PHP since 7.2 — but bundled is
not the same as **enabled**. Most distributions ship it as a separate package,
so on a fresh server it is often simply not there, and encryption fails at the
first call:

```text
PHP Fatal error: Uncaught Error: Call to undefined function sodium_crypto_box_seal()
```

You only need this if you turn **encryption** on. Plain reporting uses no crypto
and needs nothing installed.

### 1. Check whether you already have it

```bash
php -m | grep sodium
```

If that prints `sodium`, you're done — skip to step 5 and verify. Nothing
printed means it isn't loaded.

### 2. Install the extension

Match the package to the PHP version you actually run (`php -v`):

#### Debian / Ubuntu

```bash
sudo apt update
sudo apt install php8.3-sodium
```

#### RHEL / CentOS / Rocky / AlmaLinux / Fedora

```bash
sudo dnf install php-sodium
```

#### Alpine

```bash
apk add php83-sodium
```

#### macOS (Homebrew)

Homebrew's PHP already includes sodium, so there is usually nothing to do:

```bash
brew install php
```

#### Docker

On the official `php` images, libsodium's headers aren't in the base image, so
install them and compile the extension in:

```dockerfile
RUN apt-get update \
    && apt-get install -y libsodium-dev \
    && docker-php-ext-install sodium
```

#### Windows

`php_sodium.dll` ships with the official builds. Nothing to install; go straight
to step 3.

#### Building PHP from source

Configure with `--with-sodium`.

### 3. Enable it

The Debian/Ubuntu and RHEL packages normally enable the extension for you. If
`php -m` still doesn't list it, load it explicitly.

On Debian / Ubuntu:

```bash
sudo phpenmod sodium
```

Anywhere else (and on Windows), add this line to `php.ini` — run `php --ini` to
find which file is in use:

```ini
extension=sodium
```

### 4. Restart the process that serves your app

The extension is loaded at startup, so a running worker won't pick it up:

```bash
sudo systemctl restart php8.3-fpm   # PHP-FPM
sudo systemctl restart apache2      # mod_php
```

> **The gotcha that wastes an afternoon.** The CLI and FPM usually read
> **different `php.ini` files**. `php -m` tells you about the _CLI_ only, so it
> can happily print `sodium` while your web requests still crash. Check the
> runtime your app actually uses:
>
> ```bash
> php-fpm -m | grep sodium
> ```
>
> — or hit a `phpinfo()` page and search for "sodium".

### 5. Verify a sealed box actually works

The real proof is a round trip, not just a loaded extension:

```bash
php -r '
$keypair = sodium_crypto_box_keypair();
$sealed = sodium_crypto_box_seal("hello", sodium_crypto_box_publickey($keypair));
echo sodium_crypto_box_seal_open($sealed, $keypair), PHP_EOL;
'
```

If it prints `hello`, sealed boxes work and the SDK can encrypt. If it fatals on
an undefined function, the extension still isn't loaded in that runtime — go
back to step 4.

## The envelope

Under the hood the SDK sends an envelope instead of the plaintext body. The
sealed contents are the **same fields** you would otherwise send:

```json
{
    "encrypted": {
        "v": 1,
        "alg": "x25519-sealedbox",
        "key_id": "bbek_…",
        "ciphertext": "<base64( sealed_box( utf8_json_of_normal_body ) )>"
    }
}
```

On the wire the server reads only **`key_id`** (to pick your project's decryption
key) and **`ciphertext`**. The `v` and `alg` fields are advisory — sent for
forward-compatibility but not validated — so include them, but don't rely on the
server rejecting a mismatch.

The full byte-level contract — the sealing algorithm to port into a custom
client, and how encryption composes with request signing — is in
**[API Reference §11](/docs/api-reference#encrypted-payloads-optional)**.

## The response is hidden too

Sealing the request would achieve little if the server then echoed the stored
card straight back in plaintext — the payload you just encrypted would be
readable in the network tab anyway, on the way back. So the SDKs send
`X-Bb-Hide-Response` **by default** and the server omits the card from the
response body entirely. The outcome flags (`deduplicated`, `dropped`, `reason`)
still come back, since the SDK needs them to decide whether to retry.

The header is deliberately not part of the body: it stays readable when the
payload is encrypted, and it sits outside the HMAC signature, which covers the
body alone.

## FAQ

**Does it work with publishable (browser) keys?** Yes — that is the primary
case. Encryption is asymmetric, so the browser only needs the public key. The
bearer token stays visible in the network tab, but the request **body** does not.

**Does it work with secret (server) keys?** Yes. Encrypt first, then HMAC-sign
the envelope you transmit. The signature covers the ciphertext, so nothing else
changes.

**What if I send a plaintext report?** It is accepted. The server detects the
`encrypted` envelope and only decrypts when present.

**What happens if decryption fails?** A missing/revoked key or a tampered
ciphertext returns `422` with `Could not decrypt the request payload.`

**PHP says `Call to undefined function sodium_crypto_box_seal()`.** The `sodium`
extension isn't loaded in that runtime. It is bundled with PHP but usually
packaged and enabled separately — see
[Enabling PHP's sodium extension](#enabling-phps-sodium-extension). If `php -m`
lists it but your web requests still fail, you enabled it for the CLI only;
restart PHP-FPM and check `php-fpm -m`.

**Is the payload hidden from BugBoard?** No. BugBoard decrypts the report to
render the card. Encryption protects the payload in transit, at proxies/CDNs,
and in logs — not from BugBoard itself.
