board / docs / PROTOCOL.md
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# board protocol — v1

The contract between the board client and the network it runs on. The
transport, log, identity, and mailbox layers are **sueta protocol v2,
unchanged** — this document covers only what board adds on top. For the base
contract (key derivations, sealing, libp2p mesh, OrbitDB log, offline
mailbox), read `docs/PROTOCOL.md` in the chat repo:

```
git clone https://git.ardegazu.ro/chat.git
```

App salt: `"board.ardegazu.ro/v1"` — forks every derivation and storage
namespace away from every other app on the stack. The `legacy` op of the base
protocol does not exist here (documented injection surface; the projector
never accepted it).

## 1. Board links

```
#<secret>                      link-mode-only board (43ch base64url secret)
#<secret>.<creatorPub>         strict-capable board
```

`creatorPub` is the raw Ed25519 identity public key of the board's creator,
appended at creation before the link is ever shared. Policy authority is a
pure function of the link: `mode` and `remove` ops fold only when the
hardened authorship binding attributes them to `creatorPub`. There is no
genesis-op race and nothing to TOFU. A link without the suffix is a
permanently link-mode board.

The board directory (this browser's list of links) syncs between devices
holding the same identity seed exactly as sueta's room list — the directed
in-board `roomsync` plus, since social-kit 1.2.0, the sealed self-channel
`fsync` (identity-inbox mailbox, self-origin-gated): see chat's PROTOCOL.md
§6. Links stay sealed to the identity's own keys; they never reach other
people.

## 2. Content ops (the whiteboard)

One encrypted append-only log per board; every durable change is one op,
folded identically on every replica:

```ts
{ t:"add",  ts, name, el }            // el: stroke | text | note | img | shape | conn
{ t:"edit", ts, name, id, p }         // per-(id, field) LWW by (lamport clock, entry hash)
{ t:"del",  ts, name, ids: [...] }    // terminal tombstones — no resurrection
```

- Element ids are client-random 16-byte base64url (not entry hashes — live
  previews reference elements before the append resolves).
- Authorship comes from the verified entry author, never from the op payload.
- Strokes carry fixed-point (world×100) delta-encoded points, ≤ 4000 points
  (the pen splits longer gestures), optional per-point pressure.
- Connectors reference element ids as anchors (`{el, side}`) and resolve at
  render time — they follow moved shapes with no extra ops.
- Z-order is `(add clock, add hash)`; renders identically everywhere.
- Every numeric field is clamped on fold (`app/ops.cljs`) — a member sending
  NaN/1e300 coordinates cannot pin any replica's canvas.
- Pointer-rate input NEVER hits the log: live strokes/cursors/drags ride the
  sealed broadcast channel (`cur`/`draw`/`drag` payloads) at ~15–20 Hz; one
  op per completed gesture.
- Images: client-side re-encode (EXIF/GPS stripped, ≤1600px, ≤2 MiB), sealed
  unixfs blobs, CID in the `add` op. Unlike chat, EVERY image referenced by a
  live element stays pinned; pruning happens only on tombstone.

## 3. Access layer (allow-list boards)

### Two logs, ever

- **Base log** — the link-derived DB (sueta derivations, unchanged). Carries
  link-mode content, pre-switch history, and the entire control plane.
- **Vault log** — a second instance of the same machinery rooted in
  `secretV`: 32 random bytes minted by the creator at the first strict
  switch, never in the URL. `dbName`, `kEntry`, `kPayload`, `kImg`, the
  mailbox room id and `kMbx` all derive from it exactly as a room secret
  would. A URL holder cannot compute the vault's address or mailbox room —
  can't fetch, can't spam, can't correlate.

Strict content is double-sealed: an app-layer epoch AEAD inside the vault's
ordinary at-rest layers:

```
kBoard(n) = HKDF(E_n, salt=appSalt, info="board-op|v1")    AAD "v1|<roomId>|board|<n>"
kLive(n)  = HKDF(E_n, salt=appSalt, info="board-live|v1")  AAD "v1|<roomId>|blive|<n>"
kChain(n) = HKDF(E_n, salt=appSalt, info="keychain|v1")    AAD "v1|<roomId>|keychain|<n>"
```

`E_n` is the 32-byte epoch secret. Removal (and re-strict after a relax)
mints `E_{n+1}` and publishes a keychain box — `E_n` sealed under
`kChain(n+1)` — so a grant only ever delivers `{secretV, E_current}` and
history unlocks by cascading backward. Live cursor/stroke payloads on strict
boards are inner-sealed under `kLive(n)` before the normal per-sender session
seal, so strangers watching the link channel see opaque boxes.

Vault entries fold with `clock + 1_000_000` so a strict-era edit always beats
a base-era edit in per-field LWW.

### X25519 encryption identity

```
xSeed = HKDF(identity seed, salt=appSalt, info="id-x25519|v1")  → X25519 keypair
xSig  = Ed25519-sign("board-x25519|v1|" + appSalt + "|" + idPub + "|" + xPub)
```

Deterministic from the same password-manager seed — same encryption identity
on every device, nothing new stored. Curve arithmetic is @noble/curves (the
same X25519 inside libp2p-noise). A grant ("wrap") is an HPKE-shaped sealed
box: fresh ephemeral X25519 → ECDH → HKDF → AES-GCM, AAD binding room and
recipient. Sender authenticity comes from the log entry's hardened authorship
— a wrap never travels outside a signed entry.

### Control ops (base log only)

```ts
{ t:"mode", ts, name, mode:"link"|"strict", epoch, approvals:"any"|"creator",
  wraps?, chain? }                    // creator-only; re-strict carries chain
{ t:"cert", ts, xPub, xSig }          // publish certified encryption key (once per identity)
{ t:"jreq", ts, name, msg, xPub, xSig }   // the knock — any URL holder can append it
{ t:"approve", ts, to, toX, name, epoch, wrap }  // any member (default) or creator-only
{ t:"deny", ts, to }                  // hides the request; deliberately no signal back
{ t:"remove", ts, to, epoch, chain, wraps }      // creator-only; re-keys
```

Fold rules (`lib/policy.cljs`, deterministic by `(clock, hash)` order, judged
causally): creator is always a member; `approve` folds when its author is a
member at fold position (or the creator); creator approvals override a
standing removal, member approvals don't; racing approvals are an idempotent
set-add; requests fold newest-per-identity after their `xSig` verifies.

### Flows

Every step is an ordinary base-log append, so the offline mailbox carries the
whole flow — requester and approver never need to be online together:

1. **Switch**: creator mints `secretV` + `E_1`, appends
   `mode{strict, wraps}` sealing `{secretV, E_1}` to every certified member
   (including itself — that's how its own grant persists across devices).
2. **Knock**: a stranger holding the URL sees the invite-only screen, appends
   `jreq{name, msg, xPub, xSig}`, and may close the tab.
3. **Approve**: any member, whenever next online, sees name + message + key
   fingerprint; one tap appends `approve` with a wrap of the current grant.
4. **Enter**: the requester, whenever next online, folds the approve, unwraps,
   cascades the keychain, opens the vault, replays its mailbox — full history.
5. **Remove**: creator appends `remove{to, epoch:n+1, chain, wraps}` — one op,
   no DB churn; everyone re-keys, the removed identity never receives `E_{n+1}`.
6. **Relax / re-strict**: `mode{link}` returns content to the base log (the
   vault stays readable to members as history); re-stricting mints a fresh
   epoch with a keychain box.

## 4. What each party can and cannot see

| Party | base log | vault metadata | strict content |
|---|---|---|---|
| stranger with URL | pre-switch history + the control plane (who knocks, who approves — names, messages, fingerprints, opaque wraps) | nothing — can't even compute the address | nothing |
| approved member | everything | yes | granted epoch → current, plus keychain history |
| removed member | everything | **yes, forever** (holds `secretV`: entry timing, sizes, author keys) | only epochs ≤ removal |
| relay/mailbox node | ciphertext, sizes, timing, IPs; two mailbox rooms it cannot cryptographically link | — | — |

Honest limits, stated plainly: **removal is not retraction** (an ex-member
keeps what it was granted and could have copied everything anyway); there is
no forward secrecy within an epoch; the knocking ritual is URL-holder-visible
by construction (sealing requests per-member would require the requester to
know the member set — circular); image blobs of the pre-switch era, like all
base-log content, remain readable to link holders. Strict-era image blobs
ride the vault (its kImg, its mailbox room) and are stranger-unreachable.

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/board.git