mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 10:43:05 +00:00
wip
This commit is contained in:
1 parent
acc97326a5
commit
9eadb70ff9
1 file changed
+14
-12
@@ -146,28 +146,30 @@ So this is new: a periodic `LGET` of the channel's own link for groups where the
|
||||
|
||||
**Fourth, compare-and-swap on `LSET`.** With the count on a single publisher, every remaining write is human-initiated and rare, so conflicts are rare — but a lost one is still damaging, and the client-side alternative cannot prevent one. That alternative, for the record: merge onto the warm copy and `LSET` blind. A concurrent write is still lost and the writer cannot tell at the time, so reconciliation is after the fact, on the tick — the owner compares the published data against what it believes and either re-applies its change or reports it overwritten. Detecting the loss at all means durably recording what it intended, since its local state already shows the change as applied, and the loss surfaces only at the next tick. Detecting at write time instead needs a verify `LGET` after every write: a third round trip that still leaves a hole, for a clobber landing after the verify. So it is more machinery, slower, and still lossy.
|
||||
|
||||
**Compare-and-swap.** "Write this, but only if what is stored is still what I read." The writer passes a fingerprint — a hash of the stored user-data bytes its merge was based on — and the server stores the new data only if the current bytes still hash to that value; otherwise it rejects and stores nothing. A rejection *is* the detection, so no verify read is needed: re-read, re-merge, retry. Lossless.
|
||||
**Compare-and-swap.** "Write this, but only if what is stored is still what I read." The writer passes a fingerprint — a hash of the stored user-data bytes its merge was based on — and the server stores the new data only if the current bytes still hash to that value; otherwise it rejects and stores nothing. A rejection *is* the detection, so no verify read is needed: re-merge and retry. Lossless.
|
||||
|
||||
**A rejection returns the current data**, so the retry needs no read: reject → merge → write, two operations rather than three. The rejected writer always wants that data — it cannot re-merge without it — so nothing is sent in vain, and it is the same bytes it would have fetched. Nothing new leaks: `LGET` requires no signature at all for a contact queue (`Server.hs:1256`), so link data is already public to anyone holding the link, and this hands it to an authenticated recipient. The transport already carries payloads of that size — `LNK` (`Protocol.hs:698`) returns a whole `QueueLinkData` today — though a rejection needs only the user data, since the fixed half never changes and the writer already has it. It wants a distinct response rather than an `ErrorType`, which is not shaped for a 13.8 KB payload. Worth deciding now rather than later: adding it afterwards is a second version bump, whereas now it is one field on a response we are already introducing.
|
||||
|
||||
The fingerprint is of the **stored ciphertext**, not the plaintext: the server never decrypts anything, so it can only hash the bytes it already holds (`queueData qr`). It works as a version token for free, because every write carries a fresh nonce and therefore changes the stored bytes even when the plaintext is identical.
|
||||
|
||||
**Where a writer gets the fingerprint.** Only two ways, because it is a hash of ciphertext: either you produced those bytes, or you read them. Your own successful write gives it to you — the stored bytes are the bytes you just sent, hashed on the way out. Otherwise the reconcile tick's `LGET` gives it to you. There is no third source: **another owner's write is opaque until you read it**, and you cannot re-derive its fingerprint from your local plaintext, since the nonce is fresh on every write. It is persisted next to the link credentials, so a restart does not force a read.
|
||||
**Where a writer gets the fingerprint.** Only from bytes it has seen, because it is a hash of ciphertext: from its own successful write (the stored bytes are the bytes it just sent, hashed on the way out), from the reconcile tick's `LGET`, or from a rejection, which returns the current data. **Another owner's write is otherwise opaque** — you cannot re-derive its fingerprint from your local plaintext, since the nonce is fresh on every write. It is persisted next to the link credentials, so a restart does not force a read.
|
||||
|
||||
**Precisely: a write is rejected exactly when another owner has written since you last read or wrote.** So owners alternating do pay — the first write after another owner's is rejected, costing the rejection plus a read, and only then is that owner warm again. That is optimistic concurrency working as intended: you pay only when a conflict actually happened, which for human-paced writes means once per alternation.
|
||||
**Precisely: a write is rejected exactly when another owner has written since you last read or wrote.** So owners alternating do pay — the first write after another owner's is rejected, costing one extra round trip, and that owner is warm again afterwards. That is optimistic concurrency working as intended: you pay only when a conflict actually happened, which for human-paced writes means once per alternation.
|
||||
|
||||
A rejection is not free, though. `LSET` always uploads the user data padded to a fixed 13,784 bytes (`userDataPaddedLength`, `Crypto/ShortLink.hs:45`), so a rejected write wastes ~13.8 KB whatever it was changing. Two consequences. The tick must not be tuned to *prevent* rejections by keeping fingerprints warm — polling every owner on a short interval costs far more than the rejections it would save on writes that happen weekly; its frequency should be set by its real jobs, and a stale fingerprint is simply one of the things it happens to fix. And a writer frequent enough to be rejected *every time* would be a real cost rather than a tidiness concern — the subscriber count is the only one, and it is why that has a single designated publisher.
|
||||
|
||||
**The fingerprint never reaches chat.** The agent performs the encryption, so it is the only layer that sees the stored bytes; it computes the fingerprint, keeps it with the link credentials it already stores, and refreshes it on every successful write and every `LGET`. Chat calls `setConnShortLink` as it does now and gets back success or a conflict error; on conflict it re-reads, re-merges and calls again. Merge policy stays in chat, link crypto stays in the agent.
|
||||
**The fingerprint never reaches chat.** The agent performs the encryption, so it is the only layer that sees the stored bytes; it computes the fingerprint, keeps it with the link credentials it already stores, and refreshes it on every successful write, `LGET` and rejection. Chat calls `setConnShortLink` as it does now and gets back either success or a conflict carrying the current data decrypted; on conflict it re-merges its delta onto that and calls again. Merge policy stays in chat, link crypto stays in the agent.
|
||||
|
||||
**Why not hash the plaintext instead**, so every owner could compute the fingerprint without having seen the ciphertext? It does not reduce rejections: any compare-and-swap token — ciphertext hash, plaintext hash, or a plain version counter — is stale exactly when another owner has written since you last read or wrote, so the rejection rate is identical. What plaintext hashing would buy is a *deterministic* token, computable by anyone who knows the content — so an owner could keep it warm from the in-band messages it already receives (`XGrpInfo`, `XGrpRelayNew`, `XGrpMemNew`) instead of reading. Three things spoil that. The owner would have to reconstruct the stored plaintext byte-for-byte, including the JSON encoding of `userData`, and any divergence yields a mismatch that no retry resolves without a read. Those in-band messages are not a reliable ordered log — relays may drop or reorder them and there is no total order across owners — so a shadow copy drifts. And the server cannot compute a plaintext hash at all, having no key, so it would have to store a client-supplied opaque token: a storage change, and one no party can verify, where the ciphertext hash is computed on demand from bytes the server already holds. Rejections are only frequent for a high-frequency writer, and the only one is the subscriber count, which its single designated publisher already settles — one writer is always warm.
|
||||
|
||||
```
|
||||
without CAS with CAS
|
||||
O1: LGET -> {News, [R1,R2]} O1: LGET -> {News, [R1,R2]} digest a
|
||||
O2: LGET -> {News, [R1,R2]} O2: LGET -> {News, [R1,R2]} digest a
|
||||
O1: LSET {News, [R1,R2,R3]} stored O1: LSET expect a {News, [R1,R2,R3]} -> OK, now b
|
||||
O2: LSET {Daily, [R1,R2]} stored O2: LSET expect a {Daily, [R1,R2]} -> REJECTED
|
||||
O2: LGET -> {News, [R1,R2,R3]} digest b
|
||||
= R3 silently gone. It stops serving. O2: LSET expect b {Daily, [R1,R2,R3]} -> OK
|
||||
O1: warm at {News, [R1,R2]} O1: warm at {News, [R1,R2]} digest a
|
||||
O2: warm at {News, [R1,R2]} O2: warm at {News, [R1,R2]} digest a
|
||||
O1: LSET {News, [R1,R2,R3]} stored O1: LSET expect a {News, [R1,R2,R3]} -> OK, now b
|
||||
O2: LSET {Daily, [R1,R2]} stored O2: LSET expect a {Daily, [R1,R2]} -> REJECTED
|
||||
+ returns {News, [R1,R2,R3]}, digest b
|
||||
= R3 silently gone. It stops serving. O2: LSET expect b {Daily, [R1,R2,R3]} -> OK
|
||||
O1 still shows it active. = both changes survive.
|
||||
```
|
||||
|
||||
@@ -178,7 +180,7 @@ Operations per change:
|
||||
| today (single writer) | 1 — `LSET` | n/a |
|
||||
| merge, write blind | 1 — `LSET` | 1, silently lost |
|
||||
| merge, write, verify | 3 — `LGET`, `LSET`, `LGET` | 3, hole remains |
|
||||
| **compare-and-swap** | **1 — `LSET`** | 3 — rejected `LSET`, `LGET`, `LSET` |
|
||||
| **compare-and-swap** | **1 — `LSET`** | **2 — rejected `LSET`, `LSET`** |
|
||||
|
||||
So it is not a correctness tax on top of the cheap design; it *is* the cheap design. It is as cheap as today in the common case, because the fingerprint is already known, and it *removes* machinery rather than adding it: no verify read, no durable per-write intent, no retry-until-observed loop, no residual hole to document.
|
||||
|
||||
@@ -243,7 +245,7 @@ Two owners genuinely disagreeing — one adding a relay, the other removing the
|
||||
- `Crypto/ShortLink.hs` — extract `mkOwnerAuth :: OwnerId -> PublicKeyEd25519 -> PrivateKeyEd25519 -> OwnerAuth`; redefine `newOwnerAuth` on top of it; export. Removes the formula duplicated across the two repos.
|
||||
- Persist `linkRootSigKey` — new `rcv_queues` column + migration (`AgentStore.hs:2514`). Without it the non-root branch of `validateOwners` cannot work.
|
||||
- RKEY agent API — add a recipient key to a contact-link queue.
|
||||
- Compare-and-swap on `LSET` (§4.2): a new command tag carrying an expected fingerprint of the stored user data, a new `ErrorType` for the rejection, `currentSMPClientVersion` 4 → 5. The server check goes beside `lnkId' /= lnkId -> err AUTH` in the `LSET` handler (`Server.hs:1484`); the bytes to hash are already in `queueData qr`, so no storage change. Agent side: keep the fingerprint with the link credentials, refresh on every successful write and every `LGET`, and surface a conflict to chat as a distinct error. Fall back to writing blind against servers below v5.
|
||||
- Compare-and-swap on `LSET` (§4.2): a new command tag carrying an expected fingerprint of the stored user data, and a new `BrokerMsg` response for the rejection carrying the current user data — not an `ErrorType`, which is not shaped for a 13.8 KB payload. `currentSMPClientVersion` 4 → 5. The server check goes beside `lnkId' /= lnkId -> err AUTH` in the `LSET` handler (`Server.hs:1484`); the bytes to hash and to return are already in `queueData qr`, so no storage change. Agent side: keep the fingerprint with the link credentials, refresh it on every successful write, `LGET`, and rejection, and surface a conflict to chat with the current data attached. Fall back to writing blind against servers below v5.
|
||||
- A link-write API for an owner who does not own the queue. Prefer a standalone `setForeignLinkData` taking explicit creds over materialising a fake `RcvQueue`/`ContactConnection` on owner 2's device: the latter needs an `rcvDhSecret` owner 2 does not have, and risks owner 2 subscribing to owner 1's queue and racing it on inbound requests.
|
||||
- `LINK` currently notifies with the client's own `userLinkData` (`Agent.hs:1813`). Either return the server's state or drop the payload, so callers cannot mistake it for confirmation.
|
||||
- If CAS is agreed (§11): an `LSET` variant taking an expected digest of the stored user data, rejected on mismatch; the check sits beside the existing `lnkId' /= lnkId -> err AUTH` in `Server.hs:1484`. The client already has the bytes to compute the digest from its `LGET`, so no read-side change.
|
||||
|
||||
Reference in new issue
Block a user