This commit is contained in:
spaced4ndy
2026-05-12 14:09:40 +04:00
parent f1cacf4fc4
commit 94b3070d31
+212 -6
View File
@@ -62,15 +62,16 @@ Wire `sig64 || md_bytes` carries no signer ID — observers cannot
identify which owner pushed an update (preserves objective #6). Tests:
`testChainCycleStructurallyImpossible`, `testChainTooLong`.
**1.2 Signer selection (no agent change).** `encodeSignUserData` takes
any `PrivateKeyEd25519`. Channel link queue's signing key lives in
`ShortLinkCreds.linkPrivSigKey` (set at creation for creator, at
**1.2 Signer selection (no signer-path change).** `encodeSignUserData`
takes any `PrivateKeyEd25519`. Channel link queue's signing key lives
in `ShortLinkCreds.linkPrivSigKey` (set at creation for creator, at
intake 1.5 for co-owner), sourced chat-side from
`GroupKeys.groupRootKey` (`GRKPrivate rootPrivKey` ⇒ creator;
`memberPrivKey` ⇒ co-owner). Existing `setConnShortLink` already signs
correctly once `linkPrivSigKey` carries the right key — no code change
at that call site. Upstream `linkRootSigKey` TODO at
`AgentStore.hs:2514` untouched.
`AgentStore.hs:2514` resolved by 1.4a (its persistence is required
for co-owner LSET to validate against the chain root).
**1.3 Co-owner RcvQueue.** Each device holds one `rcv_queues`
row for the shared channel link queue with its own `rcv_private_key`
@@ -92,6 +93,213 @@ setQueueRecipientKeys
Concurrent races recovered by Phase 4.5 + Risk #12 pre-flight.
**1.4a Upstream simplexmq: persist `linkRootSigKey`.** Numbered
`1.4a` to avoid renumbering existing references to 1.5/1.6/1.7
throughout the plan; sequenced before 1.5 because the chat-side
intake in 1.5 (`acceptCoOwnerCreds`) depends on the agent's
persistence layer accepting a non-`Nothing` `linkRootSigKey`.
**Bug.** `ShortLinkCreds.linkRootSigKey` is in-memory only. On every
load from `rcv_queues`, `toRcvQueue` (`AgentStore.hs:2513-2514`)
hardcodes `linkRootSigKey = Nothing` (`-- TODO linkRootSigKey
should be stored in a separate field`). For a co-owner B,
`validateOwners` (`Agent/Protocol.hs:1809-1819`) at every
`setConnShortLink'` (`Agent.hs:1042-1055`) then reduces to
`validateLinkOwners (publicKey linkPrivSigKey) owners` — i.e., it
treats B's own pubkey as the root. The chain's first entry was
signed by the actual root, not by B → `signedBy` fails → `Left`
`CMD PROHIBITED` before the LSET reaches the wire. **Co-owners ≠
creator cannot LSET, ever, across all sessions.** This breaks
promote, remove, link-data update, LWW reconciliation, and the
Phase 4.4 startup pre-flight. Fix is purely persistence; do NOT
modify `validateOwners` or `validateLinkOwners`.
**1.4a.1 Schema migration.** Additive, nullable column on
`rcv_queues`. Migration name `M20260512_rcv_queues_link_root_sig_key`
(matches simplexmq's `M<DATE>_<name>` convention; current head is
`M20260410_receive_attempts`).
- *SQLite* (`Migrations/M20260512_rcv_queues_link_root_sig_key.hs`):
```haskell
m20260512_rcv_queues_link_root_sig_key :: Query
m20260512_rcv_queues_link_root_sig_key =
[sql|ALTER TABLE rcv_queues ADD COLUMN link_root_sig_key BLOB;|]
down_m20260512_rcv_queues_link_root_sig_key :: Query
down_m20260512_rcv_queues_link_root_sig_key =
[sql|ALTER TABLE rcv_queues DROP COLUMN link_root_sig_key;|]
```
Register at `Migrations/App.hs:98` (after the
`m20260410_receive_attempts` row):
`("m20260512_rcv_queues_link_root_sig_key",
m20260512_rcv_queues_link_root_sig_key,
Just down_m20260512_rcv_queues_link_root_sig_key)`. The
`rcv_queues` table is `STRICT` (`agent_schema.sql:32-70`); `BLOB`
is a permitted STRICT type, no further annotation required.
- *Postgres* (`Postgres/Migrations/M20260512_rcv_queues_link_root_sig_key.hs`):
```haskell
m20260512_rcv_queues_link_root_sig_key :: Text
m20260512_rcv_queues_link_root_sig_key =
[r|ALTER TABLE rcv_queues ADD COLUMN link_root_sig_key BYTEA;|]
down_m20260512_rcv_queues_link_root_sig_key :: Text
down_m20260512_rcv_queues_link_root_sig_key =
[r|ALTER TABLE rcv_queues DROP COLUMN link_root_sig_key;|]
```
Register at `Postgres/Migrations/App.hs:26` (after
`m20260410_receive_attempts`):
`("20260512_rcv_queues_link_root_sig_key",
m20260512_rcv_queues_link_root_sig_key,
Just down_m20260512_rcv_queues_link_root_sig_key)`. (Postgres
registry uses the bare date prefix without the leading `m`,
matching the existing `"20260410_receive_attempts"` entry; SQLite
registry uses the `m`-prefixed identifier.)
Update `agent_schema.sql` (the canonical-state mirror of all
applied SQLite migrations) to add `link_root_sig_key BLOB,`
immediately after `link_enc_fixed_data BLOB,` (currently line 61),
to keep the schema dump in sync with applied migrations.
- *Backfill.* None. The column is nullable; existing rows
retain `link_root_sig_key = NULL`, which corresponds to the
pre-fix in-memory behavior of `linkRootSigKey = Nothing` for the
creator's own queue (creator has no chained-owner authorizer).
Co-owners did not exist before this change, so no co-owner row
needs backfill.
**1.4a.2 Loader update — `toRcvQueue`.** Extend the SQL projection,
the tuple type, and the destructuring; populate
`ShortLinkCreds.linkRootSigKey` from the new column; remove the
TODO.
- `rcvQueueQuery` (`AgentStore.hs:2483-2494`): append
`q.link_root_sig_key` to the SELECT — last column of the last
`(... link_id, link_key, link_priv_sig_key, link_enc_fixed_data)`
group.
- `toRcvQueue` (`AgentStore.hs:2496-2518`): widen the final tuple
segment from
`(Maybe SMP.LinkId, Maybe LinkKey, Maybe C.PrivateKeyEd25519, Maybe EncDataBytes)`
to
`(Maybe SMP.LinkId, Maybe LinkKey, Maybe C.PrivateKeyEd25519, Maybe EncDataBytes, Maybe C.PublicKeyEd25519)`.
Bind the new value as `linkRootSigKey_` and replace the
hardcoded `linkRootSigKey = Nothing` in the `ShortLinkCreds`
construction with `linkRootSigKey = linkRootSigKey_`. Delete the
trailing `-- TODO linkRootSigKey should be stored in a separate
field` comment.
- The semantics for `shortLink` reconstruction stay
all-or-nothing on the four NOT-NULL-when-set fields
(`shortLinkId`, `shortLinkKey`, `linkPrivSigKey`,
`linkEncFixedData`); `linkRootSigKey_` is consumed inside the
`Just` branch and may be `Nothing` (creator) or `Just rootPubKey`
(co-owner). Pre-fix rows decode to `linkRootSigKey = Nothing`,
preserving prior behavior for creators.
**1.4a.3 Writers — `updateShortLinkCreds` + `insertRcvQueue_`.**
Both write sites must thread the new field. Smaller and clearer
than adding a separate setter — `linkRootSigKey` is already a
field of `ShortLinkCreds`, and there are exactly two writers.
- `updateShortLinkCreds` (`AgentStore.hs:862-871`): destructure
`linkRootSigKey` alongside the other four `ShortLinkCreds`
fields; extend the `UPDATE` SET list to write
`link_root_sig_key = ?`; thread `linkRootSigKey` into the
parameter tuple. Existing creator call site
(`Agent.hs:1072-1073`) constructs
`ShortLinkCreds linkId linkKey privSigKey Nothing (fst srvData)`
— it passes `Nothing`, which now writes a SQL `NULL` (preserving
prior behavior for creators).
- `insertRcvQueue_` (`AgentStore.hs:2077-2099`): add
`link_root_sig_key` to the INSERT column list (after
`link_enc_fixed_data`); extend the `VALUES` placeholder count
from 25 to 26; bind `linkRootSigKey =<< shortLink` in the
parameter tuple alongside the existing
`linkPrivSigKey <$> shortLink` etc. (`=<<` collapses
`Maybe (Maybe k)` → `Maybe k`, since `linkRootSigKey` is itself
`Maybe`.)
No other writers touch `link_priv_sig_key` (verified via
`grep -n "link_priv_sig_key" AgentStore.hs`); the new column is
covered by exactly the two sites above.
**1.4a.4 Intake API for co-owner persistence.** The chat-side
plan's 1.5 declares
`acceptCoOwnerCreds :: AgentClient -> NetworkRequestMode -> UserId
-> CoOwnerCredsBundle -> RcvPrivateAuthKey ->
C.PrivateKeyEd25519 -> AE ConnId`. It constructs a `ShortLinkCreds`
populated from the bundle:
```haskell
ShortLinkCreds
{ shortLinkId = bundle.shortLinkId
, shortLinkKey = bundle.shortLinkKey
, linkPrivSigKey = ownerPrivKey -- B's owner = member signing key
, linkRootSigKey = Just (bundle.rootPubKey)
, linkEncFixedData = bundle.linkEncFixedData
}
```
The `Just (bundle.rootPubKey)` is the load-bearing change — the
chat-side `CoOwnerCredsBundle.rootPubKey` already carries the
value; only the persistence side was missing. `acceptCoOwnerCreds`
constructs `NewRcvQueue { ..., shortLink = Just slCreds, ... }` and
persists via `insertRcvQueue_` (1.4a.3 above), which now writes the
fifth `link_root_sig_key` column. After this, every subsequent
`getConn`/`toRcvQueue` reload returns `linkRootSigKey = Just
rootPubKey`, and `validateOwners` at the next
`setConnShortLink` calls `validateLinkOwners rootPubKey owners` —
the chain's first entry verifies, B's LSET is permitted.
`acceptCoOwnerCreds` is the only intake API that ever supplies a
non-`Nothing` `linkRootSigKey`. The creator path
(`prepareContactLinkData` at `Agent.hs:1065-1074`, which calls
`updateShortLinkCreds` with `linkRootSigKey = Nothing`) is
unchanged in semantics — it now writes a SQL `NULL` instead of
relying on the loader's hardcoded `Nothing`.
**1.4a.5 Tests.** New tests in
`simplexmq/tests/AgentTests/ShortLinkTests.hs` (round-trip) and
`tests/AgentTests/FunctionalAPITests.hs` (LSET regression).
- `testLinkRootSigKeyRoundTrip` — insert an `rcv_queues` row via
`insertRcvQueue_` with
`shortLink = Just ShortLinkCreds { linkRootSigKey = Just k, ... }`
for a randomly-generated Ed25519 pubkey `k`; reload via
`getRcvQueuesByConnId_`; assert
`linkRootSigKey (fromJust (shortLink rq)) == Just k`. Repeat
with `linkRootSigKey = Nothing` and assert the round-trip is
also `Nothing` (creator regression).
- `testCoOwnerLSETAfterReload` — full loop:
1. Set up A as a creator via the existing short-link creation
path; LSET initial blob with one `OwnerAuth` for B
(signed by A's root key).
2. On B's `AgentClient`, call `acceptCoOwnerCreds` with a
`CoOwnerCredsBundle` carrying `rootPubKey = A_rootPub`,
B's locally-generated rcv private key, and B's owner
(= member) signing private key.
3. Restart B's agent or otherwise force a fresh `getConn` on
the channel link queue.
4. Have B call `setConnShortLink` with new
`UserContactLinkData`.
5. Assert success — pre-fix this fails with `CMD PROHIBITED`
(`validateOwners` rejects); post-fix it reaches
`addQueueLink` and the wire LSET succeeds.
- `testUpdateShortLinkCredsPreservesRootSigKey` — call
`updateShortLinkCreds` with a `ShortLinkCreds` whose
`linkRootSigKey = Just k`; reload and assert preservation.
Then call again with `linkRootSigKey = Nothing` and assert the
column is now SQL `NULL`. Guards against future drift between
the in-memory type and the writer.
This section unblocks: 1.5 (`acceptCoOwnerCreds` can now persist
`linkRootSigKey`); Phase 3 Step 4 (B's first LSET as part of
subsequent promotions); 4.1 / 4.3 / 4.4 (any LSET driven by a
co-owner).
**1.5 Co-owner credential bundle.** New `CoOwnerCredsBundle` in
`Simplex.Messaging.Agent.Protocol`. Fields: `server`, `rcvId`,
`sndId :: SMP.SenderId` (queue-level, shared across all co-owners;
@@ -748,5 +956,3 @@ chained-owner cases), `testMeshScopeRequiresOwnerIntroducer`.
the old device. The migrated user is silently locked out of
channel-owner write access until a separate device-migration flow
is added (post-MVP).
- Agent-side `linkRootSigKey` persistence; TODO at `AgentStore.hs:2514`
stays untouched.