# Orb portable archive protocol v2

October 4, 2026. This is the specification another client is meant to be written
against. It describes what is anchored on Monad testnet (chain 10143), what is
stored offchain, how the two are bound together, and how a wallet recovers only
what it is authorized to read.

Reference implementation: `lib/chat-scale.ts`, `lib/portable-chat.ts`,
`lib/archive-manifest.ts`, `lib/recovery-factor.ts`, `lib/recovery-portable.ts`,
`lib/chat-block-store.ts`. Contracts: `contracts/OrbChatIndex.sol`,
`contracts/OrbChatArchive.sol`.

**The rule that governs everything below: the chain decides, a document only
describes.** No field in any manifest, page or block grants access. Access comes
from an epoch key grant anchored in `OrbChatIndex` and from registry membership.
A client that trusts a manifest instead of the chain would widen who can read,
which is the one failure this protocol must not have.

**Message text is never written to Monad.** Only digests, sizes, stream
identities, sequence numbers and epoch numbers are anchored. Ciphertext lives in
content-addressed storage with independent replicas.

## 1. Chain anchors

Two immutable contracts. Neither has funds, approvals, an administrator, a setter
or an upgrade path.

`OrbChatArchive` — recovery identities and chat sessions:

| Member | Meaning |
| --- | --- |
| `identity(wallet) → (bytes publicKey, bytes recovery)` | `publicKey` is an uncompressed P-256 point, 65 bytes, `0x04`-prefixed and validated against the curve. `recovery` is 100–2048 opaque bytes. **Write-once:** a second `registerIdentity` reverts. |
| `sessions(wallet, signer) → uint256` | A revocable delegate signer's expiry, at most one day out. Cannot move funds or approve tokens. |
| `setSession(signer, until)` | `until == 0` revokes. |

`OrbChatIndex` — the append-only paged index:

| Member | Meaning |
| --- | --- |
| `streamKey(author, stream, community)` | `keccak256(abi.encode(community == 0 ? author : address(0), stream, community))`. A personal stream is keyed by its author; a community stream is shared and is **not** keyed by author. |
| `publish(Reference[≤32], bytes[])` | Compare-and-append: `sequence` must equal the current length or it reverts `Conflict`. Personal references require `epoch == 0`; community references require `epoch == currentEpoch(key)` **and** that the author itself holds a grant for that epoch. |
| `createEpoch(stream, community, commitment)` | Owner-only. Increments the epoch and records `commitment` and `epochAuthor`. Publications cannot use a superseded epoch. |
| `publishGrants(Grant[≤32], bytes[])` | One grant per `(key, epoch, recipient)`; a second reverts `Conflict`. Requires `registry.hasAccess(community, recipient)` and that the recipient has a registered identity. Grant block size 100–8192 bytes. |
| `catalog(author, community, offset, limit≤32)` | Lists stream keys, personal or communal. |
| `page(key, offset, limit≤32) → Stored[]` | `Stored = (digest, size, epoch, author, blockNumber)`. |
| `count`, `descriptors`, `currentEpoch`, `epochCommitment`, `epochAuthor`, `keyGrants`, `grantSizes` | Paged reads and permission state. |

`Reference = (address author, bytes32 stream, bytes32 community, uint256 sequence, bytes32 digest, uint256 size, uint256 epoch, uint256 deadline)`
`Grant = (address author, bytes32 stream, bytes32 community, uint256 epoch, address recipient, bytes32 digest, uint256 size, uint256 deadline)`

Both are authorized by EIP-712 signature under domain
`{name:"OrbChatIndex", version:"2", chainId:10143, verifyingContract:<index>}`.
The signer must be the author, or a delegate whose `sessions` expiry is at least
the reference `deadline`. `deadline` must be within one day.

A legacy third contract path, `OrbChatArchive.publish`, carries **ciphertext in
its events**. It is not part of this protocol and remains a labelled pilot; a new
client should implement the index path only.

## 2. Well-known streams

All are `keccak256` of a UTF-8 string (ethers `id`).

| Purpose | Stream | Scope |
| --- | --- | --- |
| Personal history for one source | `id('orb.private.archive:' + wallet.toLowerCase() + ':' + source)` | personal (`community = 0`, `epoch = 0`) |
| Archive manifest | `id('orb.archive.manifest.v2')` | personal |
| Passphrase recovery factor | `id('orb.recovery.factor.v2')` | personal |
| A community channel | `id('orb.channel.v2:' + community.toLowerCase() + ':' + channelIdentity.toLowerCase())` | community |
| Legacy single shared channel | `id('orb.shared.general.v2')` | community |

`channelIdentity(community, room) = id('orb.channel.v2:' + community.toLowerCase() + ':' + room)`,
where `room` is the hosted room id, at most 120 characters. A channel therefore
has a stable identity derived from the community and the room, and its own stream
key, epoch counter and grant list. Two channels of one community are two
independent access domains.

## 3. Blocks

A block is raw bytes, addressed by `digest = sha256(bytes)` and expressed as a
CIDv1 with raw codec `0x55` and SHA2-256 multihash. The CID is a pure function
of the digest, so a gateway addressed by `{cid}` and one addressed by `{digest}`
must return identical bytes.

Limits: 100 bytes minimum, 262,144 maximum (`BLOCK_BYTES`). A reader must reject
a response whose length differs from the anchored `size` or whose SHA-256 differs
from the anchored `digest`, and must then try the next replica.

Every block is a JSON object. The exact key set is part of the format; a client
must reject a block with extra or missing keys rather than ignore them.

### 3.1 `orb.archive.batch` — an encrypted archive page

Keys, sorted: `context, index, payload, scheme, type, v`, plus an optional
`compression` key whose only allowed values are absent (equivalent to `none`)
and `deflate-raw`. A block with any other key, or with an unsupported
compression value, must still be rejected. `v = 2`,
`type = "orb.archive.batch"`, `index` = the index contract address,
`scheme ∈ {"wallet","epoch"}`, and `context` must equal exactly:

```json
{"v":2,"chain":10143,"index":"<lowercase index>","author":"<lowercase>","stream":"<lowercase>","community":"<lowercase>","sequence":"<decimal string>","epoch":<number>}
```

- `scheme = "wallet"` — personal. Requires `community == 0` and `epoch == 0`.
  `payload` keys sorted: `ciphertext, ephemeral, fingerprint, iv, v, wraps`;
  `v = 2`; `iv` is 12 bytes base64; `ephemeral` is a 65-byte uncompressed P-256
  point base64; `fingerprint` is the author's registered chat public key base64;
  `wraps` has exactly one entry, keyed by the author's lowercase address, whose
  value is `b64(12-byte iv):b64(48-byte wrapped key)` — one wrapped content key
  for one recipient, produced by ECDH P-256 + HKDF-SHA-256 + AES-256-GCM
  (`encryptForRoom` in `lib/e2ee.ts`).
- `scheme = "epoch"` — shared. Requires `community != 0` and `epoch != 0`.
  `payload` keys sorted: `ciphertext, iv`. AES-256-GCM under the epoch key, with
  the context string above as additional authenticated data. **One ciphertext for
  every authorized reader**; per-reader material is the small grant block below.

Decrypted plaintext:

- `wallet` scheme: `{"context":"<same context string>","part":<ArchivePart>}`.
  The inner `context` must match, which binds the plaintext to this exact stream
  position as well as the envelope.
- `epoch` scheme: the `ArchivePart` JSON directly.

Plaintext limit is 98,304 bytes (`PLAIN_BYTES`), at most 100 message rows per
page.

### 3.2 `orb.archive.key` — an epoch key grant

Keys sorted: `context, index, payload, type, v`. `v = 2`,
`type = "orb.archive.key"`, and

```json
{"v":2,"chain":10143,"index":"<lowercase index>","key":"<lowercase stream key>","epoch":<number>,"recipient":"<lowercase>"}
```

`payload` is a direct-encryption payload (`encryptDirect`, `lib/e2ee.ts`) whose
plaintext is `{"context":"<that string>","key":"<base64 of the 32-byte raw epoch key>"}`.
The digest and size are anchored by `publishGrants`; `keyGrants(key, epoch, recipient)`
returns that digest and `grantSizes` the size, so a reader can fetch exactly its
own grant and no one else's.

After decryption the reader **must** check `sha256(rawKey) == epochCommitment(key, epoch)`.
That is what proves the key belongs to the epoch the chain records, rather than
being a substitute.

### 3.3 `orb.recovery.factor` — an anchored passphrase factor

Keys sorted: `context, index, payload, type, v`. `v = 2`,
`type = "orb.recovery.factor"`. `payload` is the seven fields of a
`PassphraseEnvelope` plus `fingerprint = base64(sha256(utf8(registered chat public key base64)))`,
which binds the factor to one registered key. Size 100–8192 bytes, anchored in
the wallet's own `orb.recovery.factor.v2` personal stream.

## 4. Recovery identity and factors

The operational key is a non-extractable ECDH P-256 pair. Its public key is
registered onchain as 65 raw bytes. It is never exported to disk, and a client
must import it non-extractable.

The onchain `recovery` bytes are one of two JSON documents:

- **v1 envelope** — `{v:1, publicKey, salt, iv, ciphertext}`. `salt` 16 bytes,
  `iv` 12 bytes, `ciphertext` the PKCS#8 private key sealed with AES-256-GCM
  under HKDF-SHA-256 of a signature-derived secret. Additional authenticated data
  is `orb-chat-backup-v1\n<context>\npublicKey: <publicKey>`.
- **v2 bundle** — `{v:2, publicKey, factors:{signature:<v1 envelope>, passphrase:<PassphraseEnvelope>}}`.
  Both factors seal the *same* key, so either one recovers it. It must fit 2048
  bytes; a bundle of two factors is roughly 900.

The signature-derived secret is `r || s` of `personal_sign` over a fixed private
message (`lib/e2ee-recovery.ts`). **This is only stable while the wallet signs
that message identically.** `secp256k1` with RFC 6979 is deterministic, but a
different client, a firmware change, a smart-contract or passkey signer may
produce a different valid signature — and because `registerIdentity` is
write-once, a factor that stops reproducing cannot be replaced.
`scripts/test-independence.mjs` demonstrates exactly this: it produces a second
cryptographically valid signature from the same key, shows the v1 envelope then
fails to open, and shows passphrase recovery still succeeding.

So the passphrase factor is not a convenience. It is the recovery route that does
not depend on signature reproducibility, on Privy, or on browser storage.

`PassphraseEnvelope = {v:1, publicKey, kdf:"pbkdf2-sha256", iterations, salt, iv, ciphertext}`,
`iterations` between 210,000 and 3,000,000 (default 600,000). The passphrase is
12–256 characters and must not start or end with a space. WebCrypto offers no
Argon2; PBKDF2-HMAC-SHA256 is the available choice and the cost is stored, not
assumed, so it can be raised later without invalidating older envelopes.

Two distinct contexts bind a factor, and mixing them makes an envelope
unopenable — so each has a name in code:

- `bundleContext(origin, wallet)` — for a factor inside the onchain bundle. The
  bundle is written before any index is chosen, so it binds to the identity
  contract only.
- `anchoredContext(origin, wallet, index)` — for an `orb.recovery.factor` block,
  which travels through the index and is therefore bound to that deployment. A
  factor block copied to another Orb will not open there.

`origin` is `urn:orb:portable-chat:v1:eip155:10143:<lowercase identities address>`.

A wallet already registered with a v1 envelope cannot be rewritten onchain, so it
anchors an `orb.recovery.factor` block in its own personal stream instead.
Discovery is by `catalog`/`page` on `streamKey(wallet, id('orb.recovery.factor.v2'))`,
bounded to 8 pages of 32.

## 5. Archive pages and the manifest

`ArchivePart` is a union.

**v1 page** — `{v:1, type:"structure"|"history", snapshot, title, source, rows, communities?, created}`.
`rows` is at most 100 entries of `{id:number, body:string ≤10000, person:string ≤100, name:string ≤160, created:number, reply?}`.
`snapshot` groups pages produced by one export run. `communities`, when present,
is the legacy name index: at most 20 of `{id, name ≤160, rooms: ≤40 of {id, name, kind}}`.

**v2 manifest** — one per wallet, always personal, always `wallet`-scheme, and
bound on decryption to the reading wallet and to this exact deployment. Keys,
sorted: `communities, created, format, identity, integrity, personal, rows, snapshot, source, title, type, v`.

- `v = 2`, `type = "manifest"`, `format = "orb-archive/2"`, `rows = []`.
- `identity` keys sorted: `chainId, identities, index, publicKey, registry, wallet`.
  `chainId` must be 10143 and all four addresses must match the deployment the
  reader is using, or the manifest is rejected.
- `integrity` keys sorted: `block, digest, encoding, keyWrap, note`, fixed to
  `sha2-256`, `cidv1-raw`, `aes-256-gcm`, `ecdh-p256-hkdf-sha256`.
- `communities` — at most 64 of `{channels, community, name, owner, registry}`.
  Each `channels` entry, at most 128, has keys sorted
  `access, channel, from, kind, messages, name, pages, scope, sequences, stream, to`,
  where `sequences` is an inclusive `[from, to]` pair and `access` has keys sorted
  `epoch, granted, grantedCount, rule, truncated`.
- `personal` — at most 256 of `{from, messages, pages, sequences, source, stream, title, to}`.

Bounds: `granted` holds at most 256 addresses and `truncated` says so when the
real roster was larger. `grantedCount` records the true number. **The list is
advisory.** The authoritative answer to "who could read this channel in this
epoch" is `keyGrants(streamKey, epoch, wallet) != 0` on the index contract.

A manifest is superseded, not appended to: a reader takes the newest one.

## 6. Discovery algorithm for an independent client

Given: a wallet address, a recovery secret, an RPC for chain 10143, and at least
one reachable block gateway. No Orb API, no cookie, no browser storage.

1. Read `chat-index.deployment.json` from the client bundle. Verify `chainId`,
   that the index runtime bytecode matches the bundled artifact, and that
   `identities` and `registry` match what the contracts report.
2. Recover the chat key: read `identity(wallet)`, decode the bundle, and open
   either factor. Nothing is persisted unless the client chooses to.
3. Enumerate `catalog(wallet, 0x00..00, offset, 32)` and page through it. For each
   key, `descriptors(key)` gives `(author, stream, community)`.
4. Read the manifest: the personal stream `id('orb.archive.manifest.v2')`, newest
   page first. Decrypt with the wallet scheme and verify the identity binding.
5. For each personal stream, `page(key, offset, limit≤32)`, fetch each block from
   a gateway, verify size and SHA-256, decrypt, and check the inner context.
6. For each community channel the manifest names, compute its stream key, read
   `keyGrants(key, epoch, wallet)`. If zero, the wallet has no key and must stop —
   it does not try other members' grants. Otherwise fetch that grant block by its
   anchored digest and size, decrypt it directly to this wallet, verify the epoch
   commitment, then read the channel's pages with that epoch key.
7. A page whose block cannot be fetched or verified is skipped and counted, never
   shown as absent history.

## 7. Storage

Encrypted blocks are stored by providers configured on the server, never by URLs
supplied in a request. Three kinds (`lib/chat-block-store.ts`):

- `folder` — digest-sharded files under an absolute path, `fsync` + atomic rename.
- `kubo` — IPFS Kubo RPC `block/put?format=raw&mhtype=sha2-256&pin=true`, then
  `block/get` readback.
- `http` — any content-addressed store: `PUT {base}{digest-hex}` with the raw
  bytes, `GET {base}{digest-hex}` back. A reference implementation anyone can run
  is `infra/chat-archive/block-host.mjs`.

A replica counts toward quorum only after its bytes are read back and re-hashed.
Below quorum, `storeEncryptedBlock` throws and **nothing may be reported as
durable**. Default quorum is 2, so a deployment with zero providers fails closed:
writes return 503 and block reads 404. That is intentional.

A CID proves identity, not retention. A gateway that stops serving a block makes
it unrecoverable, and no key can recreate it. Two folders on one computer are not
two failure domains.

Gateways are tried in order, at most 8, 12 s each, HTTPS only — except loopback
HTTP for a local fixture. A published manifest must not carry a loopback URL:
whoever downloads the client is not on the operator's machine. When a client is
served by the Orb application itself, that application's own block endpoint is
prepended at runtime instead (`readerGateways` in `lib/independent-assets.ts`).

## 8. Limits

| Limit | Value |
| --- | --- |
| Block bytes | 100 – 262,144 |
| Page plaintext | 98,304 bytes, ≤100 rows |
| Grant block | 100 – 8,192 bytes |
| References per transaction | 32 |
| Grants per transaction | 32 |
| Catalog / page limit | 32 |
| Pending references per account | 64 |
| Upload budget | 8 MiB per account per UTC day, charged once per digest |
| Gateways | ≤8, 12 s each |
| Manifest | ≤64 communities, ≤128 channels each, ≤256 listed grants, ≤256 personal streams |
| Chat session | ≤1 day, revocable |
| Legacy full-ciphertext batch | ≤8 entries, 12,000 bytes total |

## 9. What this does not do

Archived community names, channel lists and message copies are personal
historical records. They do not migrate live memberships, permissions, roles,
moderation, invites or ownership. A recovered archive is not proof that someone
else wrote its copied text; only a newly published, signed envelope authenticates
its publisher. Attachments, media, voice and video are not copied. A personal copy
cannot widen who may read a channel, and a former recipient cannot be made to
forget content already decrypted for them: there is no MLS and no Double Ratchet,
so there is no forward secrecy after a long-term key is compromised.

Continuing a **live** community is a different problem from recovering an archived
one, and needs synchronization or federation between servers. See
[independence](orb-independence.md) for that boundary.
