eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Tamper-evident hosting
2
3**Status: design.** This document settles the design questions left open in
4issue #573 (spun out of #550's provider-marketplace discussion) and defines the
5build order. The first slice β€” chained, signed checkpoints over an append-only
6event journal β€” is implemented; everything else here is committed design, not
7code.
8
9The one rule says: *if the server is compromised, the attacker gets nothing.*
10Today that holds for **confidentiality** β€” message content, names, and files
11are ciphertext under keys the server never sees. This work extends the rule to
12**integrity**: a malicious host (a compromised us, or any third-party provider
13we later delegate spaces to) must not be able to silently rewrite membership
14or history. "Silently" is the load-bearing word β€” a host can always destroy or
15withhold data; the goal is that *every* such act leaves evidence a client or
16monitor can catch, and where possible **prove** to a third party.
17
18## What a malicious host can do today
19
201. **Attack membership** (the key-distribution MITM). A joiner's new public
21 key travels through the server unauthenticated: the invite secret `T`
22 authenticates the *server β†’ joiner* direction (the wrapped key blob only
23 opens under a `T`-derived key), but nothing in the *joiner β†’ members*
24 direction binds the published public key to `T`. The host can substitute
25 its own key as "the new member," receive wrapped thread keys from honest
26 members, and re-wrap to the real joiner β€” a full read of every thread the
27 fake member is wrapped into.
282. **Roll back.** Serve an old DB snapshot. Clients that were online notice
29 their own messages missing; clients that were offline can't tell.
303. **Withhold or reorder** (bounded nuisance): drop or delay individual
31 events. Clients notice gaps against their own state but can't prove
32 misbehaviour.
33
34Three mechanisms close these, in dependency order:
35
36| Mechanism | Closes | Depends on |
37|---|---|---|
38| Signed checkpoint chain (this slice) | 2 becomes provable | nothing |
39| Key transparency log | 1 | checkpoint chain, commons log |
40| Client sampling verification | 3 detectable; escrow conformance | checkpoint chain, restore-read |
41
42---
43
44## Part 1 β€” the event journal and the checkpoint chain (built)
45
46### Why a journal: the message table is not append-only, on purpose
47
48Edits are in-place `UPDATE`s and unsend is a hard `DELETE` β€” the ciphertext is
49really gone, which is the product promise. A Merkle tree over the *current*
50`messages` rows would therefore change under legitimate edits and unsends, and
51"legitimate mutation" would be indistinguishable from "host rewrote history."
52
53So the unit of commitment is not table state but an **append-only event
54journal**: every insert, edit, and delete appends one journal row in the same
55transaction. The journal stores **hashes of ciphertext, never ciphertext** β€”
56unsend still destroys the blob; what the journal retains is that *an* event
57happened, not what it said.
58
59```
60event_journal(
61 seq INTEGER PRIMARY KEY AUTOINCREMENT, -- total order, never reused
62 ts INTEGER, -- unix seconds, server clock
63 kind TEXT, -- 'msg' | 'edit' | 'delete' (roster kinds join later)
64 msg_id INTEGER, -- the messages.id this event concerns
65 thread_id TEXT,
66 blob_hash TEXT -- sha256 hex of the ciphertext ('' for delete)
67)
68```
69
70**Privacy accounting.** Every journal column is metadata the server already
71holds (it stores the blobs and serves them by thread and id). The one *new*
72retention is temporal: after an unsend, the server previously forgot the
73message ever existed; now a hash-shaped fossil (`msg_id`, `thread_id`, `ts`,
74`blob_hash`) remains. That is inherent to tamper evidence β€” you cannot make
75deletion verifiable while also forgetting it happened β€” and it reveals nothing
76about content: a SHA-256 of AES-GCM ciphertext is doubly opaque. This is the
77same bargain Certificate Transparency makes, and it is why the journal must
78never grow a plaintext column.
79
80### Checkpoints
81
82A background sweep (same shape as the mini-app scheduler: a `time.Tick` loop
83delegating to a pure, test-callable unit of work) periodically commits to all
84new journal rows:
85
86- **Leaf hash**: `SHA-256("lchat-ckpt-leaf\n" β€– canonical encoding of the
87 journal row)`. Domain-separated so leaves can't be confused with interior
88 nodes or other protocol hashes.
89- **Batch root**: an RFC 6962-style Merkle tree over the leaves
90 `[prev.seq_to+1 … seq_to]`. Batching means each checkpoint is O(new events),
91 not O(history), and inclusion proofs (a later slice) are proof-in-batch plus
92 the chain to the head.
93- **Chain hash**: `SHA-256("lchat-ckpt-chain\n" β€– prev_chain_hex β€– root_hex β€–
94 seq_to)`. Each checkpoint commits to its predecessor, so two checkpoints
95 presenting different histories for the same range are *cryptographic proof*
96 of equivocation, and serving a state older than a head a client has seen is
97 *provable* rollback β€” not just an anomaly.
98- **Signature**: Ed25519 over the base64 JSON payload string, exactly the
99 `release.go` signed-manifest pattern (verify the signature over the encoded
100 string first, decode after β€” no canonicalization to get wrong).
101
102Checkpoints live in a `checkpoints` table and are served, member-token-gated,
103at `GET /api/checkpoints` (cursorable, newest known to the caller onward).
104Member-gated rather than public because the chain leaks activity volume and
105timing; monitors hold member credentials anyway (see distribution, below).
106
107### The signing key is not the security
108
109The per-instance Ed25519 seed lives **in the DB config by default**, minted on
110first use. That placement is deliberate and operational: every path that moves
111instance state β€” hibernation wake, `VACUUM INTO` migration, escrow restore β€”
112carries *only* the DB, and a key that silently changes on routine ops would
113make every legitimate restore read as forgery, poisoning the system with false
114alarms. The trade is that anyone holding a DB copy (including the escrow) can
115sign as the instance; we accept it, because no key location survives "the host
116is malicious" anyway β€” the host runs the process and holds the seed. **The
117security property is chain continuity plus external witnessing of heads** β€” a
118host (or a DB-copy holder) can forge a fresh chain, but it cannot make one
119that both (a) extends the head a client or the escrow already observed and
120(b) omits what that head committed to. The signature's job is modest: it makes
121a checkpoint attributable *given* a witnessed head, and stops anyone without
122the seed from fabricating checkpoints in the instance's name. Two supporting
123choices: every checkpoint payload **embeds its own public key**, so a key
124change is an explicit, investigable event rather than old checkpoints suddenly
125failing verification; and `LCHAT_CHECKPOINT_KEY_FILE` lets an operator keep
126the seed in a file outside the DB instead, owning the carry-it-across-restores
127burden themselves.
128
129### What this slice deliberately does not do
130
131No inclusion proofs endpoint yet, no client-side verification yet, no roster
132events in the journal yet (that's key transparency's job, below), and no
133UI. It is the evidence *substrate*: from the moment it deploys, every instance
134is accumulating a committed history that later verifiers can hold it to.
135Deploying this early matters β€” evidence only covers events after the journal
136starts. Messages that predate the journal are **baselined** at migration (one
137`msg` row each, committing to the ciphertext as it stood at upgrade): the
138baseline can't retroactively prove the past, but without it, editing or
139unsending an old message would journal an event for a message the journal
140never saw β€” a built-in false alarm.
141
142---
143
144## Part 2 β€” the three open design questions, settled
145
146### Q1: Log granularity β€” one commons log with sealed space identifiers
147
148**Decision: one global, commons-operated transparency log; spaces appear
149under sealed (HKDF-derived) identifiers. Per-space logs are rejected.**
150
151A per-space log kept by the space's own host adds nothing against that host β€”
152the adversary operates the log. The trust win comes precisely from the log
153living in a *different* trust domain than the space's host, which is the whole
154point once spaces can be hosted by third-party providers (#575): a provider
155can't equivocate about a roster without the commons log catching it, and a
156compromised commons log can't read anything β€” it holds only commitments.
157
158One global log also amortizes what actually costs effort: monitoring. CONIKS
159died and WhatsApp KT shipped largely on this axis β€” auditing one log is a
160commons-scale job; auditing ten thousand per-space logs is nobody's job.
161
162**Sealed identifiers.** A space appears in the log as
163`space_tag = HKDF(instance_secret, "lchat-kt-space-tag")` β€” stable within a
164space (members and monitors derive it; they hold the instance secret, which
165already exists as key material wrapped to every member) but meaningless to the
166log operator and unlinkable across spaces. Roster entries under a tag are the
167sealed shape of "key K joined / rotated / left," carrying no names β€” screen
168names are E2EE and stay that way. The log learns: some space, some volume of
169membership churn. It must never learn which space, or who.
170
171**What the instance's own chain (Part 1) is, then:** the per-space *history*
172commitment stays local-with-witnessing (it commits every message event β€”
173far too voluminous and too revealing for a global log). The global log carries
174only (a) roster/key events under sealed tags and (b) periodic instance chain
175heads, which is what makes local chains fork-evident. Two layers, one trust
176story: the instance chain says *what this host claims happened*; the commons
177log pins *which claim the host is stuck with*.
178
179### Q2: The invite `#fragment` bootstraps the log entry β€” extends, never replaced
180
181**Decision: `T` stays the root of trust for admission; the log makes what `T`
182authorized auditable. Concretely, a join produces a log entry that carries a
183`T`-derived binding of the joiner's public key, verified by the inviter's
184client.**
185
186The mechanism mirrors the existing `deriveInvite` derivations (id, wrap key,
187claim secret β€” all HKDF domain-separated from `T`). Add one more:
188
189- `bind_key = HKDF(T, "lchat-invite-bind")`, held by whoever minted the link
190 (their client already had `T`) and derivable by the joiner.
191- At claim time the joiner publishes, alongside its new public key,
192 `bind_mac = HMAC(bind_key, joiner_pubkey)`.
193- The **inviter's client** β€” the one party besides the joiner who ever held
194 `T` β€” verifies `bind_mac` against the *published roster key* before it (or
195 anyone it vouches to) wraps a single thread key to the new member, and then
196 signs the join's log entry as attester.
197
198This closes the substitution attack at its exact seam: a host that swaps the
199published key can't produce a valid `bind_mac` for its own key (it never held
200`T` β€” it stores only `claim_hash`), so the inviter's client refuses to wrap
201and the join fails loudly instead of succeeding silently.
202
203Subsequent lifecycle events are self-certifying without `T`:
204
205- **Key rotation / add-device**: signed by the key being replaced (continuity
206 chain rooted in the `T`-bound original).
207- **Removal**: signed by an admin identity, itself a `T`-rooted chain (the
208 setup invite is the genesis entry).
209- **Wrap discipline**: clients wrap thread keys only to keys whose lineage
210 appears in the log β€” "verify before wrap" is the client-side rule that makes
211 the log load-bearing rather than advisory.
212
213Bots and the eleven CLI follow the same path β€” they are ordinary members whose
214`T` arrives via the agent-invite flow; nothing special-cases them.
215
216**Why not replace the fragment with the log?** Because the fragment is the
217only *out-of-band* channel in the system β€” the one path the host never sees.
218A log can prove consistency (everyone sees the same roster) but cannot prove
219*legitimacy* (that a roster entry was authorized by a human who held the
220link). Consistency without an anchor just means the host lies to everyone
221identically. `T` supplies legitimacy; the log makes it globally consistent
222and equivocation provable. They compose; neither substitutes for the other.
223
224### Q3: Checkpoint distribution β€” escrow first, member gossip second, commons log third
225
226**Decision: three witnessing channels, shipped in this order, each valuable
227alone.**
228
2291. **Escrow replica (near-free, ships with Part 1).** The `checkpoints` table
230 rides the existing Litestream replication to S3, and S3 versioning
231 timestamps every generation of it β€” a target the *provider* doesn't control
232 once escrow is commons-held (#550 Part 10). Honest history: monotone chain
233 growth across replica generations. Rollback: a later generation whose head
234 is an ancestor of an earlier generation's. The host could stop replicating,
235 but going dark is itself the detectable event the escrow lease already
236 surfaces. Limit: a fully self-hosted instance with no escrow gets nothing
237 from this channel β€” hence channels 2 and 3.
2382. **Member gossip inside E2EE traffic (the novel one, and ours to use:
239 members already share an authenticated, host-opaque channel β€” the chat
240 itself).** Clients periodically tuck the head they last verified
241 (`seq_to`, `chain_hash`) inside their normal encrypted payloads; peers
242 compare against their own view. A host that forks β€” serving member A one
243 history and member B another β€” is betrayed by its own transport: it can't
244 read the gossip to filter it, and dropping messages wholesale is exactly
245 the withholding that members notice. Split-view detection with zero new
246 infrastructure, working even for self-hosted spaces with no escrow and no
247 commons. This is the distribution answer the issue was looking for.
2483. **Commons log witnessing (with Part 3/KT).** Instances push heads under
249 their sealed tag on a slow cadence; the commons log's own consistency
250 proofs then pin every instance's chain globally. Strongest guarantee,
251 biggest dependency β€” which is why it's last, not first.
252
253Ordering rationale: 1 is a table already replicating; 2 is a small client
254change with outsized reach; 3 requires operating a new commons service and
255should be built once, for KT, not twice.
256
257---
258
259## Part 3 β€” key transparency (design sketch, next after this slice)
260
261With Q1–Q3 settled, KT's shape is: roster/key events become journal kinds
262(`join`, `rotate`, `remove`) carrying the Q2 evidence (bind MACs, continuity
263signatures); the instance chain commits to them like any event; instances
264publish roster events + chain heads to the commons log under their sealed tag;
265clients enforce verify-before-wrap; monitors (any client, or the commons)
266audit for equivocation. The commons log itself is CT-shaped: append-only,
267Merkle, publicly auditable, dumb β€” it stores sealed commitments and serves
268proofs, nothing else. Detailed protocol (proof formats, monitor cadence,
269anti-flap for offline devices) is its own design pass **after** the journal
270substrate has soaked in production. It is deliberately not specified further
271here: the failure mode of KT designs is specifying the whole cathedral before
272the foundation has carried load.
273
274**Part 3a β€” roster/key journal events (built).** The first, safe increment:
275membership changes are journaled like message events, so they're committed to
276the signed checkpoint chain and a monitor catches a server rewriting the
277roster. A member's first key publish appends a `join` event; removal appends a
278`remove`. They reuse the journal's generic columns as the kind's detail record
279(`thread_id` = the member's user id, `blob_hash` = sha256 of the public key for
280`join` / empty for `remove`, `msg_id` unused) β€” the leaf encoding already
281covers these fields, so no wire-format change. Re-registering the same key
282(add-device) is not a new join. This touches no crypto and no join-path
283decision; it just makes the roster tamper-evident on the instance chain.
284
285**Part 3b β€” the bind MAC + verify-before-wrap (next, review-gated).** The
286security-critical increment that closes the key-substitution attack (Q2):
287`bind_key = HKDF(T, "lchat-invite-bind")`; the joiner publishes
288`bind_mac = HMAC(bind_key, joiner_pubkey)` at claim; the inviter's client
289verifies it against the published roster key before wrapping any thread key,
290and clients enforce verify-before-wrap generally. Because it changes the
291join/key-distribution path β€” the crown jewel of the one rule β€” it ships as its
292own PR with an adversarial review pass and a canary before it reaches any real
293space.
294
295## Part 4 β€” client sampling verification (design sketch)
296
297Because clients hold plaintext truth, whole-DB verification needs no
298proofs-of-retrievability: each client periodically samples k message ids *from
299its own local store*, fetches those rows through a **restore-read endpoint
300over the escrow replica** (not the live host β€” the point is to check what
301escrow would restore), checks each blob decrypts and matches, and β€” once
302inclusion proofs land β€” proves each against a committed root. Independent
303sampling across members covers the DB probabilistically at trivial per-client
304cost, and the pass/fail signal doubles as the client-visible end of provider
305conformance, which #575's payment gating needs. Depends on: checkpoint chain
306(shipped), inclusion-proof endpoint, restore-read endpoint. Build after KT's
307journal extension, before the marketplace.
308
309## Out of scope (unchanged from #573)
310
311Provider-served **web assets** remain the weakest trust point: a malicious
312host can serve JavaScript that exfiltrates keys, and no log catches that.
313Signed releases + native apps carry the answer for now; a Code Verify-style
314extension is the eventual one. Separate work, tracked in #550 Part 10.
315
316## Build order
317
3181. **Event journal + signed checkpoint chain** β€” this slice. Server-only,
319 additive migration, zero client changes. Starts the evidence clock.
3202. **Member gossip of chain heads** (Q3 channel 2) β€” small client change,
321 makes forks member-detectable everywhere, no new services.
3223. **Inclusion proofs endpoint** + verify-on-load in clients.
3234. **KT journal kinds + bind MAC at claim + verify-before-wrap** (Q2) β€” the
324 membership fix, still commons-free.
3255. **Commons log + sealed tags + monitors** (Q1, Q3 channel 3) β€” the global
326 equivocation pin. Gates #575.
3276. **Restore-read + client sampling** (Part 4). Gates #575's payment
328 conformance.
329