# Orb independence: audit and plan

October 4, 2026. Audit of what exists today, then the staged plan. Everything here
is **local source**; no Git push, no deployment, no mainnet and no funded
operation was performed to produce it.

The goal being audited: if Orb's domain, API, database or VPS disappear, a person
can open an independent client, connect a wallet they control, and recover the
data they are entitled to. Someone else can host that client. The chain is used
to **discover and verify** records, ownership, versions and permissions. Message
text is never written to Monad; content stays encrypted, offchain, in
independent copies. A CID or hash onchain proves a file's identity, not that the
file is still stored.

## Fixed boundary for this work

No deployment is allowed, so the onchain surface is **frozen**. Both contracts
were measured on public testnet with `eth_getCode`, not read from a manifest:

| Contract | Address | Runtime | Mutable? |
| --- | --- | --- | --- |
| `OrbChatIndex` | `0xa44874D7Bb59DDadc264e3C6Cbe04dD11464625e` | 7,693 bytes | No setter, no admin, no funds |
| `OrbChatArchive` (identities) | `0xdb4fa091B3D86f5c1835564Ed2E796AF0d2C5923` | 6,044 bytes | `registerIdentity` is once per wallet |
| `OrbOpenCommunityArena` (registry) | `0x0f40156299Fb43979DB0F602b5287bab41Df4B59` | 21,429 bytes | Separate product |

Every design decision below has to fit that surface. Two consequences matter:

- `OrbChatArchive.registerIdentity` reverts if a public key already exists
  (`contracts/OrbChatArchive.sol:33`). An already-registered wallet **cannot**
  receive a new onchain recovery envelope. A second recovery factor for those
  wallets has to be anchored as an offchain block referenced by the index.
- `OrbChatIndex` already supports per-`(stream, community)` epochs, per-recipient
  key grants, compare-and-append sequences and paged catalogs of at most 32. That
  is enough for channel-scoped permissions without a new contract.

## What already works (verified on this machine today)

**Discovery and verification onchain.** `streamKey(author, stream, community)`,
`catalog`, `descriptors`, `count`, `page`, `currentEpoch`, `epochCommitment`,
`epochAuthor`, `keyGrants`, `grantSizes` are all read directly by the client
(`lib/chat-scale.ts:74`, `components/orb-scalable-chat.tsx:39`). No Orb API is
involved in a read.

**Content-addressed blocks.** CIDv1, raw codec `0x55`, SHA2-256 multihash; the
CID is a pure function of the digest anchored onchain (`lib/chat-scale.ts:23`).
Two envelope types: `orb.archive.batch` v2 with `scheme: 'wallet' | 'epoch'`, and
`orb.archive.key` v2 for a grant. `validateBlock` binds the envelope to the exact
`batchContext` (chain, index, author, stream, community, sequence, epoch), so a
block copied into another stream or sequence fails to decrypt as well as to
validate.

**Read-back verification.** `fetchVerifiedBlock` enforces the exact `size` and
SHA-256 per replica, skips a bad replica, caps at 8 gateways and 12 s each, and
ends with an honest error: a recovery key cannot recreate missing storage
(`lib/chat-scale.ts:82`).

**Write confirmation.** `storeEncryptedBlock` writes with `fsync` + atomic rename,
reads the file back and re-hashes it; the Kubo path does `block/put` then
`block/get` readback with size and hash checks. Quorum is enforced *before*
anything may be called durable, and the failure is a hard throw, not a warning
(`lib/chat-block-store.ts:44`). Windows concurrent replacement is accepted only
after exact size/hash verification.

**Permission enforcement.** `publishGrants` requires `registry.hasAccess`, a
registered recipient identity, and one grant per `(key, epoch, recipient)`; a
second grant reverts `Conflict`. Publishing into a community stream requires the
current epoch *and* that the author holds a grant for it. `createEpoch` is
owner-only and rotates, so new publications cannot use an old epoch
(`contracts/OrbChatIndex.sol:63-91`). Epoch keys are verified against the
onchain `epochCommitment` after decryption (`lib/chat-scale.ts:70`).

**Upload authorization.** Session + profile-bound wallet + signature + matching
context + bounded body + per-account 8 MiB/day budget reserved atomically and
charged once per digest even across retries (`lib/chat-upload-budget.ts`).

**The independent client is already relocatable and already accepts an external
wallet.** `orb-independent.tsx:62` builds a `BrowserProvider` from the injected
wallet and calls `eth_requestAccounts`; `configureIndependentAssets` resolves
every asset relative to the bundle, not to an Orb host (`lib/independent-assets.ts`).

**Suites green today**, re-run during this audit: `chat:scale:test` (30,000
messages → 300 replicated blocks; 4,000 communities → 125 bounded pages),
`chat:test`, `chat:test:api`, `chat:test:ui` prerequisites, `test-chat-public-assets`
(313 assets, no configured secret present), `test-independent-kit` (ZIP/CRC,
allowlist, SHA-256, tamper refusal, arbitrary-folder relocation). `tsc --noEmit`
exits 0. The kit is fresh: `orb-independent.zip` (00:59) postdates the last
component edit (00:19) and `client.sha256` matches `sha256sum` exactly.

## What is missing, with evidence

1. **Recovery requires browser storage.** `lib/portable-chat.ts:46` calls
   `requireIdentityStorage()`, which throws when IndexedDB cannot open, and
   `saveRecoveryIdentity` throws too. This directly blocks the required
   demonstration, which must recover **without browser local storage**.
2. **The recovery secret is the signature's `r||s`.** `lib/e2ee-recovery.ts:18-23`
   derives the wrapping secret from `personal_sign` output. That is deterministic
   only if the signer uses RFC 6979. The guard at `lib/portable-chat.ts:59` signs
   twice **at registration** and refuses on mismatch — it does not protect against
   a different client or wallet implementation producing a different signature
   later. Combined with once-only `registerIdentity`, a non-deterministic signer
   means the onchain envelope can never be opened and cannot be replaced. This is
   a permanent-loss path, not a cosmetic risk.
3. **No portable second factor.** Signature is the only recovery route, so a
   smart-contract wallet, ERC-1271 signer or passkey wallet has no path at all.
4. **Storage providers are not pluggable.** `lib/chat-block-store.ts` knows only
   `folder` (absolute path) and `kubo` (Kubo RPC). There is no generic HTTP
   provider, so choosing another provider means editing code. And
   `ORB_CHAT_KUBO_NODES` is unset locally, so blocks exist only on this machine's
   disk and `ipfs.io`/`dweb.link` will never serve them.
5. **The public manifest sends third parties to `localhost:3112`.**
   `public/independent/chat-index.deployment.json` lists it as `gateways[0]`.
6. **There is no documented manifest format.** `ArchivePart`
   (`lib/portable-chat.ts:14`) is `{v:1, type:'structure'|'history', snapshot,
   title, source, rows, communities?, created}`. The optional `communities`
   carries only `{id, name, rooms:[{id, name, kind}]}`: no permissions, no
   membership, no channel-to-stream binding, no negotiated format version, and
   `rows.person` is a display name rather than a wallet. Another client cannot
   interpret an archive authoritatively from it.
7. **Channels have no scope of their own.** The shared archive uses one stream
   `orb.shared.general.v2` per community (`components/orb-scalable-chat.tsx:47`)
   and publishes only the text its owner typed (line 55). With no per-channel
   stream there is no way to honour per-channel permission, and real hosted
   channel history is not exported at all.
8. **The cross-device pending queue skips community references.**
   `components/orb-scalable-chat.tsx:25`: `if(row.ref.community!==ZeroHash)continue;`.
9. **Shared publication cannot be resumed.** `createEpoch` → N × `publishGrants`
   → content block. An interruption leaves an epoch with partial grants and no
   content block; retrying rotates the epoch and orphans those grants.
10. **None of this is committed, and the published site serves the old client.**
    HEAD has no `chat:*` script and no `lib/chat-scale.ts`, `contracts/OrbChatIndex.sol`
    or `components/orb-scalable-chat.tsx`. `public/independent` has 11 of 325
    files tracked, and they are *not* gitignored — simply never added.
    `https://joinorb.online/independent/chat-index.deployment.json`
    returns 404, as do `chat.deployment.json` and `contributions.deployment.json`.
    *Correction, October 5: this finding is out of date. The tree is committed and
    clean, the chat scripts and `lib/chat-scale.ts` are tracked, and the live
    origin returns 200 for `/proofs`, `/independent/chat.deployment.json`,
    `/independent/chat-index.deployment.json` and `/independent/orb-independent.zip`,
    with the live `bundle-manifest.json` identical to the working tree (313 files,
    zero differing digests). The public origin serves the October 4 build, so work
    committed after that is still local-only.*
11. **Archived recovery and live continuity are not separated anywhere.** The
    docs state that membership is not migrated, but no document draws the line
    between "recover an archived history" (needs no Orb server) and "continue a
    live community" (needs sync or federation between servers).
12. **`.env.example` documents 8 of the 15 `ORB_CHAT_*` variables the code reads.**
    The consequential omissions are `ORB_CHAT_RELAYER_KEY` (the legacy keeper;
    documented only in prose) and `ORB_CHAT_READ_GATEWAYS`.
    *Correction, October 5: `.env.example` now documents all of them, including
    `ORB_CHAT_MIN_FREE_BYTES`.*

Also worth stating plainly: `OrbChatArchive.publish` kind 3 places **ciphertext**
in an event. That is the legacy full-ciphertext pilot. It is not message text, but
it is content onchain, so the index path — digest and size only — is the one this
plan treats as the protocol, and the legacy path stays labelled legacy.

## What was implemented after the audit

Same day, local source only. No push, no deployment, no mainnet, no funds.

**Protocol and manifest.** `lib/archive-manifest.ts` defines a versioned,
self-describing document — `format: "orb-archive/2"` — covering identity,
communities, channels, per-channel epochs, who was granted a key, page ranges and
integrity claims. Validation is strict and total: an unknown key, a wrong bound or
a mismatched deployment rejects the whole document rather than being ignored.
Specified in [the archive protocol](orb-archive-protocol.md), which is written to
be implemented from, not reverse-engineered.

**Recovery.** Two changes, both aimed at the failure the audit called permanent.

- Recovery no longer requires browser storage. `requireIdentityStorage` was moved
  off the read path; when IndexedDB is unavailable the key is held in memory for
  the tab and the result is marked `ephemeral`. Creating an identity still
  requires durable storage, because `registerIdentity` is write-once and an
  interrupted registration must be resumable with the same key.
- A **passphrase factor** (`lib/recovery-factor.ts`) seals the same P-256 key under
  PBKDF2-SHA256, so recovery needs only the wallet address, the passphrase, an RPC
  and a block provider — no Privy, no signature, no storage. New registrations
  carry it inside the onchain v2 bundle; a wallet already registered with a v1
  envelope anchors an `orb.recovery.factor` block in its own personal stream
  through `app/api/orb/chat-archive/recovery-factor/route.ts`, which reuses the
  existing queue, lease and keeper rather than adding a worker. Both routes are
  reachable from the Portable chat panel.

The signature factor is kept, and its fragility is now demonstrated rather than
assumed: the suite produces a second cryptographically valid signature from the
same key, shows the v1 envelope then fails to open, and shows passphrase recovery
unaffected.

**Storage providers.** `lib/chat-block-store.ts` takes `ORB_CHAT_PROVIDERS`, so
`folder`, `kubo` and `http` replicas are configuration rather than code. The `http`
kind is any content-addressed store, and `infra/chat-archive/block-host.mjs` is a
dependency-free reference host that anyone can run — it verifies the SHA-256 of
every write against the address it was sent to. Quorum is still enforced after
read-back, and still fails closed.

**Gateways.** The loopback URL is gone from the shipped manifest and from the
deploy default. A published manifest must not send a downloader to the operator's
machine; instead `readerGateways` prepends the same-origin block endpoint at
runtime, only when the bundle is actually being served by an Orb application.

**Permissions.** Channel identity and stream derivation
(`channelIdentity`, `channelStream`) give each channel its own stream key, epoch
counter and grant list, so two channels of one community are two access domains.
The demonstration publishes exactly that: one channel granted to two wallets, one
granted to a single wallet, and asserts the granted member reads its channel, the
ungranted member reads nothing, another channel's epoch key does not open this
one, a member cannot read another wallet's personal archive, and an outsider
cannot be granted at all because registry access is the floor.

**Demonstration.** `npm run independence:test` passes. It publishes a history and
a manifest to two block hosts running as separate processes with separate storage
and separate bearer secrets, anchors them on a local Monad testnet chain, and then
recovers with no Orb API, no database, no cookie, no Privy, no browser storage and
no signature — asserting the recovered text is byte-identical, that every byte came
from a configured independent provider, that the original API was never contacted,
and that the archive still recovers after one provider is killed.

Existing suites were re-run after every stage: `chat:scale:test`, `chat:test`,
`chat:test:api`, `independent:test` and `tsc --noEmit` all pass.

## What is still missing

Stated plainly, because the demonstration passing is not the same as the feature
being finished.

1. **The public origin lags the working tree.** The audit's finding 10 no longer
   stands: the tree is committed and clean, the chat scripts and
   `lib/chat-scale.ts` are tracked, and the live origin returns 200 for `/proofs`,
   `/independent/chat.deployment.json`, `/independent/chat-index.deployment.json`
   and `/independent/orb-independent.zip`, with the live `bundle-manifest.json`
   identical to the working tree (313 files, zero differing digests). The public
   origin serves the October 4 build, so work committed after that is still
   local-only until the next deploy.
2. **The community archive UI still publishes one typed paragraph.** The
   channel-scoped primitives exist and are proven by the suite, but
   `sharedArchive()` in `components/orb-scalable-chat.tsx` still uses a single
   `orb.shared.general.v2` stream and publishes only the text its owner types. It
   does not export real hosted channel history, and it does not yet use
   `channelStream`. Exporting a private channel to a public registry changes who
   can read it, so it needs the reviewed membership bridge and epoch-rotation
   policy `docs/orb-chat-scale.md` already calls for — not a silent default.
3. **Shared publication still cannot be resumed.** `createEpoch` → grants → content
   block is not idempotent; an interruption leaves an epoch with partial grants and
   no content, and retrying rotates the epoch and orphans them.
4. **The cross-device pending queue still skips community references**
   (`if(row.ref.community!==ZeroHash)continue;`), and grant publication still makes
   three serial RPC calls per member with no progress reporting.
5. **The browser panel's passphrase route has no browser suite.** The protocol,
   the anchored factor and the permission boundaries are covered in Node; the
   click-through in `orb-portable-chat.tsx` is not yet exercised by
   `chat:test:ui`.
6. **No offsite replica was actually used.** Two processes on one host prove the
   mechanism and the verification, not retention across a failure domain. Public
   IPFS pinning, a retrieval drill and a capacity measurement are still outstanding,
   and a CID still is not a retention guarantee.

## Remaining dependencies

| Dependency | Needed for | Needed for reading? |
| --- | --- | --- |
| An RPC serving chain 10143 | discovery, verification, publication | Yes — and the reader trusts it; these are not state proofs |
| At least one block provider that still retains the ciphertext | recovery of content | Yes |
| The recovery secret | opening the chat key | Yes |
| Privy | logging into the *embedded* wallet | **No** — an external wallet works, and reading needs no wallet signature at all |
| Orb API and database | preparing new archive pages, anchoring a factor, live community | **No** |
| Operator gas wallet + keeper | publishing queued references on the owner's behalf | No — the owner can publish from their own wallet |

Independence is claimed only for **recovering an archived history**, and only to
the extent the demonstration above shows. It is not claimed for continuing a live
community, which needs synchronization or federation between servers; see
[self-hosting](orb-self-hosting.md).

