This commit is contained in:
spaced4ndy
2026-07-16 11:50:58 +04:00
parent 8ba56c36d1
commit cc34743800
+16 -7
View File
@@ -54,11 +54,11 @@ O1 on acceptance, via a durable worker (mirror `runRelayRequestWorker` — multi
2. `LSET` — publish `owners = [oa1, …, oaN, mkOwnerAuth memberId2 memberKey signingKey]`.
3. `x.grp.mem.role memberId2 GROwner (Just memberKey)` → relays → subscribers.
**Step 1 needs an additive RKEY.** `RKEY :: NonEmpty RcvPublicAuthKey` *replaces* the whole list (`updateKeys st sq rKeys = … q {recipientKeys = rKeys}`), and nothing reads it back `QueueInfo` carries `qiSnd`/`qiNtf`/`qiSub`/`qiSize`/`qiMsg` and no keys. So a writer must already know every key in the list. The creator does, having issued every RKEY. **A promoted owner does not, and never can** so owner 2 adding owner 3 would RKEY a list built from its own knowledge and silently evict owner 1's link-write capability. That defeats the point of equal owners.
**Step 1 needs the key set readable, and `RKEY` given compare-and-swap.** `RKEY :: NonEmpty RcvPublicAuthKey` *replaces* the whole list (`updateKeys st sq rKeys = … q {recipientKeys = rKeys}`), so a writer must know every key it wants to keep — and nothing reads it back: `QueueInfo` carries `qiSnd`/`qiNtf`/`qiSub`/`qiSize`/`qiMsg` and no keys. The creator knows the set, having issued every RKEY. **A promoted owner does not**, so owner 2 adding owner 3 would RKEY a list built from its own knowledge and silently evict owner 1's link-write capability.
Publishing each owner's recipient key in link data would fix the knowledge gap but not the race: `RKEY` has no compare-and-swap, so two owners adding concurrently would each write a list missing the other's addition. An **additive** RKEY needs neither — the caller supplies one key, cannot evict anyone by construction, and two concurrent additions both land. Removal keeps the existing replace semantics, and is deferred (§11). The server caps the list.
Add the keys to `QueueInfo`: `QUE` is already a recipient-authorised command returning queue state, so this is a field, not a mechanism, and only a recipient sees it. `RKEY` then takes an expected fingerprint of the set, as `LSET` does (§4.2), or two owners adding concurrently each replace it with a list missing the other's addition; the rejection returns the current set, so a retry needs no second read. `recipientKeys :: NonEmpty` (`QueueStore.hs:37`) prevents emptying it. One concurrency pattern for both mutable server-side objects.
This is the same version bump as compare-and-swap, so the marginal cost is one command.
The set is **anonymous** — public keys with nothing tying a key to an owner. That is sufficient here: adding means appending and keeping the rest, which needs no idea whose the others are. Removing a *named* owner does need that mapping, and it is part of removal's design (§11), not this one.
**The role event is the commit point.** M must not treat itself as an owner on sending the acceptance, only on receiving `x.grp.mem.role` for itself — which the existing `xGrpMemRole` self-branch (`Subscriber.hs:3338`) already applies. O1 sends it only after 1 and 2 succeed, so the event means *the link says you are an owner*. Set the role earlier and any failure or crash between acceptance and LSET leaves M believing it is an owner while absent from the chain: it would write link data and be refused, and sign admin messages that every subscriber rejects, since its key is not in the published owners.
@@ -210,7 +210,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 it; export. Kills the cross-repo duplicate.
- Persist `linkRootSigKey``rcv_queues` column + migration (`AgentStore.hs:2514`).
- Additive RKEY (§3): a new command taking one key and appending it, capped. `RKEY` as shipped replaces the list and nothing reads it back (`QueueInfo` has no keys), so a promoted owner cannot use it without evicting the others. Additive also removes the concurrent-addition race, since `RKEY` has no compare-and-swap. Plus the agent API.
- Recipient keys readable + `RKEY` compare-and-swap (§3): add the keys to `QueueInfo` (`QUE` is already recipient-authorised), and give `RKEY` an expected fingerprint with the rejection returning the current set — the same shape as `LSET`, so one pattern covers both. Without the read a promoted owner cannot RKEY without evicting the others; without the fingerprint two concurrent additions clobber. Plus the agent API.
- Link-write API for an owner that does not own the queue. Prefer a standalone `setForeignLinkData` taking explicit creds over faking an `RcvQueue`/`ContactConnection`: the latter needs an `rcvDhSecret` owner 2 must not have, and risks it subscribing to owner 1's queue and racing on inbound requests.
- CAS on `LSET` (§4.2): new command tag carrying the expected fingerprint; new `BrokerMsg` for the rejection carrying current user data; `currentSMPClientVersion` 4 → 5. Server check beside `lnkId' /= lnkId -> err AUTH` (`Server.hs:1484`) — bytes to hash and return are already in `queueData qr`, so no storage change. Agent keeps the fingerprint with link creds. Fall back to blind writes below v5.
- `LINK` notifies with the client's own `userLinkData` (`Agent.hs:1813`) — return the server's state or drop the payload, so callers cannot mistake it for confirmation.
@@ -241,9 +241,9 @@ Out-of-band verification closes it and already exists. `APIVerifyGroupMember` (`
**Any owner can destroy the channel.** `Server.hs:1249` — any recipient key authorises any recipient command, including `DEL` and `LDEL`, with no per-command scoping. Consistent with any-owner-decides; recorded because it is stronger than any chat-level action: the link address is in the published profile, so its loss cannot be repaired by re-publishing.
**RKEY revocation is a race.** `updateKeys` replaces the whole list and has no compare-and-swap, so O1 can evict O2 and vice versa; last writer wins. Additions avoid this via the additive command (§3); removal cannot, which is input to owner removal (§11).
**RKEY as shipped is a replace with no compare-and-swap**, so two owners writing the list concurrently evict each other; last writer wins. §3 gives it a fingerprint, so a loser is rejected and retries against the current list instead. Removal is then replace-minus-one under the same rule; `recipientKeys :: NonEmpty` (`QueueStore.hs:37`) prevents emptying the list, so two owners removing each other cannot leave the link unwritable — one survives, arbitrarily.
**Chain order is load-bearing.** A reordered or partially-published chain makes link data unreadable to every client and bricks joins. Publish in `owner_auth_index` order and validate locally before `LSET` — the agent rejects a bad chain anyway, but as `CMD PROHIBITED`, not a legible error.
**Chain order is load-bearing, and entries are never deleted.** A reordered or partially-published chain makes link data unreadable to every client and bricks joins. Publish in `owner_auth_index` order and validate locally before `LSET` — the agent rejects a bad chain anyway, but as `CMD PROHIBITED`, not a legible error. Dropping an entry breaks every owner it transitively signed, so removal revokes the key and keeps the entry (§11).
**Creator anonymity is weaker than the overview states.** It claims owners are indistinguishable "provided multiple owners were signed by the root key". Once owner 2 signs owner 3 with its own key the chain shows who delegated to whom, and root-signed entries identify the creator's cohort. Qualify it.
@@ -263,7 +263,16 @@ Cases: owner 2 promoted, verified by an existing subscriber (TOFU) and a later j
## 11. Open
**Owner removal.** Not designed. Removing a chain entry invalidates every owner it transitively signed (§4.3); SMP-level revocation is a mutual-eviction race (§8); and there is **no last-owner protection** in the backend — `APIMembersRole` blocks only `selfSelected`, so two owners can already demote each other into an ownerless channel, and RULE-19 is UI-only. The backend guard is worth adding regardless.
**Owner removal.** Shape settled, details open. Revoke the leaver's link-write key, broadcast the removal, and **keep its `OwnerAuth` entry**, so every owner it transitively signed stays valid and `validateLinkOwners` never breaks. The chain becomes append-only for good — which is already its merge rule (§4.2) — and the entry is inert once the key is revoked, since a signature that cannot be written reaches no one. Enforcement is at SMP, not in the chain: dropping the key from `recipientKeys` means the removed owner can no longer authorise *any* recipient command, including adding a key back.
Open:
- **Attributing a recipient key to an owner.** The server's key set is anonymous (§3), which suffices for adding but not for removing a named owner: you must know which key is theirs. Neither home is clean. On the server it would mean telling the SMP operator which app-level identity holds which key. In link data it is worse than it looks: link data is encrypted under a key derived from the link URI, so *anyone holding the link* can read it — an SMP operator who joins the channel could match the published map against its own `recipientKeys` and learn which owner is behind each request, tying an owner's session to its channel identity. The map wants to be owner-only — distributed among owners rather than published — which is a mechanism this plan does not have. Settle before designing removal; it is the reason removal is not a small addition.
- **A revocation marker.** Otherwise a new joiner reading link data recreates the removed owner as `GROwner``createLinkOwnerMember` builds owner records from the chain, so an entry left for signature validity would also read as current authority. A flag on the entry, or a list of revoked member IDs in chat's `userData`; either is chat-side.
- **The chain is append-only and link data is capped** at 13,784 bytes, shared with the profile and its image; an entry is ~108 bytes. Owner churn is therefore bounded, and exceeding the cap would brick the link.
Note removal is **not a security boundary against a malicious owner**: any owner can already destroy the channel (§8), and with additive RKEY could have added a second recipient key it controls before being removed. It is an administrative operation for cooperative cases.
**Last-owner protection.** Missing today, independent of removal: `APIMembersRole` blocks only `selfSelected`, so two owners can already demote each other into an ownerless channel, and RULE-19 is UI-only. Worth adding regardless.
**Count-publisher handover.** "The next owner takes over when the first stops writing" needs a rule, but not a good one: getting it wrong means two owners briefly publishing the same relay-sourced number, one rejected. Settle during implementation.