# Shade Inbox — Async Store-and-Forward (V3.6)

A relay that holds **ciphertext blobs with TTL** so senders can deliver
to recipients who happen to be offline. The relay never sees plaintext,
never holds private keys, and never knows who is talking to whom in
plaintext form (only addresses and bytes-per-blob).

This document covers:

- Setup (server side, single-binary)
- Client integration (`@shade/inbox`)
- Threat model — *what the relay actually sees*
- Operational tuning (TTL, quotas, prune cadence)
- Wire-level reference

---

## 1. Server setup

The inbox server is built into the same `@shade/server` standalone
container that ships the prekey server, on the same port. Routes are
namespaced under `/v1/inbox/*`.

### Docker (single binary, both services)

```bash
docker run -d --name shade \
  -p 3900:3900 \
  -v shade-data:/data \
  -e SHADE_PREKEY_DB_PATH=/data/shade-prekeys.db \
  -e SHADE_INBOX_DB_PATH=/data/shade-inbox.db \
  -e SHADE_INBOX_PRUNE_INTERVAL_MINUTES=5 \
  ghcr.io/zyon-no/shade:latest
```

### Postgres (multi-instance / shared infra)

```bash
docker run -d --name shade \
  -p 3900:3900 \
  -e SHADE_PREKEY_PG_URL='postgres://shade:***@db/shade' \
  -e SHADE_INBOX_PG_URL='postgres://shade:***@db/shade' \
  ghcr.io/zyon-no/shade:latest
```

Tables are auto-created (`shade_inbox_owners`, `shade_inbox_blobs`,
sequence `shade_inbox_seq`). If you only set `SHADE_PREKEY_PG_URL`, the
inbox falls back to the same database; set
`SHADE_INBOX_PG_URL='-'` to disable that fallback and run the inbox
in-memory (only useful for short-lived test deployments).

### Env vars

| Var                                    | Default                  | Effect                              |
| -------------------------------------- | ------------------------ | ----------------------------------- |
| `SHADE_INBOX_DB_PATH`                  | _(unset → memory)_       | SQLite file path                    |
| `SHADE_INBOX_PG_URL`                   | _(unset → falls back)_   | Postgres connection string          |
| `SHADE_INBOX_PRUNE_INTERVAL_MINUTES`   | `5`                      | How often expired blobs are dropped |

### Embedding in your own Hono app

```ts
import { Hono } from 'hono';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { createInboxRoutes, MemoryInboxStore } from '@shade/inbox-server';

const crypto = new SubtleCryptoProvider();
const store = new MemoryInboxStore();

const app = new Hono();
app.route('/', createInboxRoutes(store, crypto));

export default { port: 3901, fetch: app.fetch };
```

---

## 2. Client integration

`@shade/inbox` is the recipient/sender SDK. It composes on top of
`@shade/sdk` — Shade still owns encryption + the ratchet; the inbox
layer is just durable transport.

### Wiring

```ts
import { Shade } from '@shade/sdk';
import { Inbox } from '@shade/inbox';

const shade = new Shade(/* ... */);
await shade.initialize();

// Lift the identity keys we already have.
const identity = await shade.getManager().getIdentityKeyPair();

const inbox = new Inbox({
  baseUrl: 'https://inbox.example.com',
  ownAddress: shade.myAddress,
  crypto: shade.crypto,
  signingPrivateKey: identity.signingPrivateKey,
  signingPublicKey: identity.signingPublicKey,
  pollIntervalMs: 30_000,
});

// Receive: hand each fetched blob to Shade.receive.
inbox.onIncoming(async (raw) => {
  const envelope = decodeEnvelope(raw.ciphertext);
  // The inbox does not authenticate the sender — Shade.receive does,
  // by way of the recipient's session/ratchet/identity-pin.
  const senderAddress = /* derive from your own metadata channel */;
  await shade.receive(senderAddress, envelope);
  return senderAddress;
});

inbox.start(); // registers + begins flush + poll loops

// Send: encrypt with Shade, hand the envelope to the inbox.
const envelope = await shade.send('bob@example.com', 'hi');
await inbox.send({ recipientAddress: 'bob@example.com', envelope });
```

### Push-trigger hook

The inbox is *pull-based* — recipients only see new blobs when they
poll. Most apps want a wake-up nudge when new content lands. Vendor it
yourself (FCM / APNs / email / WebPush):

```ts
inbox.onMessageQueued(async (recipient, msgId) => {
  await fcm.send(recipient, { kind: 'shade-inbox', msgId });
});
```

The recipient device wakes, runs `inbox.tick()`, and pulls the blob.

### Durable queue

The default in-memory queue is fine for short-lived processes. For a
service that must survive restart, plug in your own `OutgoingQueueStore`
backed by SQLite/Postgres/IndexedDB:

```ts
const inbox = new Inbox({
  // …
  queueStore: new MyDurableQueueStore(),
  cursorStore: new MyDurableCursorStore(),
});
```

Same idea for the receive cursor — without persistence, every restart
re-downloads everything currently within TTL.

### Errors

- **Decrypt failure** in your handler keeps the blob on the server (no
  ack). The next poll re-fetches it — useful when the ratchet temporarily
  rejects a message because of out-of-order delivery.
- **`msgId/ciphertext` mismatch** is a relay-tampering canary. The Inbox
  client recomputes the hash and emits `inbox.message_decrypt_failed`
  *without* acking, so an operator can investigate before the blob
  silently expires.
- **Network failure** on PUT keeps the entry in the local queue with an
  `attempts` counter; default cap is 10 retries before the entry is
  dropped (configurable via `maxAttempts`).

---

## 3. Threat model — what the relay actually sees

| Knows                                              | Doesn't know                              |
| -------------------------------------------------- | ----------------------------------------- |
| Recipient address (path parameter)                 | Recipient real identity (it's pseudonymous) |
| Sender's per-PUT signing public key                | The mapping sender-pubkey → real identity |
| Number of blobs queued for an address              | Plaintext content                          |
| Approximate ciphertext size                        | Sender-recipient pair beyond bytes-pari   |
| Per-blob TTL (in the row's `expires_at`)           | The ratchet/X3DH state                    |

### Privacy posture

- **Sender-recipient graph leaks at the byte-pari level.** A passive
  observer of the relay (or its DB dump) can correlate sender pubkey ↔
  recipient address ↔ blob size. Mitigations:
  - Recipients can use **address hashes** instead of human-readable
    addresses (the address grammar accepts any `[a-zA-Z0-9][a-zA-Z0-9:_\-.]{0,255}`,
    so `sha256(real-address || salt)` works).
  - Senders can rotate their per-PUT signing key per session; the relay
    only verifies the signature and never persists the key.
- **TTL leaks reachability.** A sender's PUT silently dropping after 7
  days is itself a signal. Operators can normalize TTLs (clamp every
  PUT to a fixed 7-day window) to flatten this.
- **Operator can DoS a recipient** by deleting their queue. Mitigation:
  recipient ack happens *after* successful decrypt, so a malicious
  delete just forces re-send by the original sender.

### What the relay can NOT do

- **Read plaintext** — the ratchet/AEAD layers run client-side.
- **Forge a sender** — every PUT is Ed25519-signed by the sender's
  per-PUT key; the relay rejects bad signatures with 401.
- **Inject a foreign blob** — the recipient client recomputes
  `sha256(ciphertext)` and refuses anything that doesn't match the
  stored `msgId`.
- **Replay an old PUT** — the signed `signedAt` field has a ±5-minute
  window (matches the prekey-server's policy); replays past that window
  return 409.

### Storage-DoS

`maxBlobBytes` (default 1 MiB) caps a single PUT.
`maxBlobsPerAddress` (default 1000) caps the recipient's queue depth —
PUTs past the cap return 400 with a structured `inbox.quota_rejected`
event so operators can alert. Combine with per-IP rate limits at the
edge (the built-in token bucket is in-memory and not multi-instance).

---

## 4. Wire reference

All bodies are JSON. Multi-byte fields are base64-standard encoded.

### `POST /v1/inbox/register` (TOFU)

```json
{
  "address": "bob",
  "signingKey": "<base64 Ed25519 public key>",
  "signedAt": 1716057600000,
  "signature": "<base64 Ed25519 signature over canonical body>"
}
```

- 200 — registered (or idempotent re-register with same key).
- 401 — different key already owns this address, or signature failed.

### `POST /v1/inbox/:address` (PUT blob)

```json
{
  "senderSigningKey": "<base64 sender Ed25519 public key>",
  "msgId": "<lowercase hex sha256(ciphertext)>",
  "ciphertext": "<base64 wire bytes from encodeEnvelope()>",
  "ttlSeconds": 604800,
  "signedAt": 1716057600000,
  "signature": "<base64 sender signature>"
}
```

- 200 with `{ msgId, receivedAt, idempotent: false }` — first store.
- 200 with `idempotent: true` — duplicate PUT folded into the first row.
- 400 — `msgId` mismatch, ciphertext too big, or address quota exceeded.
- 401 — bad signature or stale `signedAt`.
- 404 — recipient address never registered.

### `POST /v1/inbox/:address/fetch` (signed challenge)

```json
{
  "address": "bob",
  "sinceCursor": 0,
  "signedAt": 1716057600000,
  "signature": "<base64 recipient signature>"
}
```

Returns:

```json
{
  "blobs": [
    {
      "msgId": "<hex>",
      "ciphertext": "<base64>",
      "receivedAt": 1716057601234,
      "expiresAt": 1716662401234
    }
  ],
  "cursor": 1716057601234,
  "hasMore": false
}
```

Pass the returned `cursor` as `sinceCursor` next time. Pages cap at
`fetchPageLimit` (default 100); keep calling with the new cursor while
`hasMore === true`.

### `DELETE /v1/inbox/:address/:msgId` (signed ack)

Body:

```json
{
  "address": "bob",
  "msgId": "<hex>",
  "signedAt": 1716057600000,
  "signature": "<base64 recipient signature>"
}
```

- 200 with `{ ok: true }` — row removed.
- 200 with `{ ok: false }` — row was already gone (also idempotent).
- 401 — recipient signature failed.

### `DELETE /v1/inbox/register/:address`

Same auth shape as ack. Drops every queued blob.

---

## 5. Acceptance test mapping

| V3.6 spec criterion                                        | Test                                                           |
| ---------------------------------------------------------- | -------------------------------------------------------------- |
| Async delivery without online overlap                      | `lifecycle.test.ts → "100 messages delivered…"`                |
| DB-dump leaks no plaintext / sender-recipient graph        | Server stores only `address \|\| msgId \|\| ct \|\| expires_at`; verified by `routes.test.ts` schema asserts |
| Replay PUT with same `msgId` is idempotent                 | `routes.test.ts → "idempotent on duplicate ciphertext"`         |
| Restart preserves blobs                                    | `lifecycle.test.ts → "persistence across restart"` + sqlite-store reopen |
| Bit-flip on stored ciphertext rejected on the client       | `lifecycle.test.ts → "Tamper resistance"` + client `client.test.ts → "tamper detection"` |
