Files
mantra-kmp/composeApp/schemas/press.mantra.compose.database.MantraDatabase/9.json
Kgothatso Ngako a8f6638325 feat: sign a room's key state into being, at the room's own key
Two changes that turned out to be one. A room's key state stops being something
its creator announces and becomes something the group signs, and every FROST
signature moves from the group's root threshold key to the key derived at the
room's own path -- which is the room's id. The second is what makes the first
worth having: a key state is now signed by the very key it names.

Supersedes the announcement introduced in a909108, and changes the author of
every event the group signs, including the artifacts of 786c060.

## The key state is proposed, not announced

a909108 had the room's creator write the GroupKeyState row, say so in the room
on kind 30326, and every receiver keep it if the room's id rederived from the
key it named. That check was sound and is still here -- a state that does not
rederive its own room is dropped, whoever sent it -- but it left the first thing
a group ever does as the one thing a single member decides alone.

So the key state goes through the door everything else the group says goes
through. GroupKeyStateManager.announce becomes propose, which opens a
FrostSigningEvents.PROPOSAL over an unsigned 30326 and writes no row. The state
comes into existence when a quorum has signed it, on every device at once,
applied by FrostSigningManager.complete like any other signed proposal:

    creator  --[ 30320 proposal over an unsigned 30326 ]-> everyone
    ...members approve, nonces, signer set, partials, aggregate...
    everyone --applies the signed 30326 locally-->  GroupKeyState row

Nothing waits on it. Between creating a room and that session completing there
is no state to read, and completedKey's rederivation scan -- kept from before
the table existed -- is what keeps the room signable in the meantime, including
for the key-state session's own members. That is the only reason a bootstrap
here does not deadlock, and the scan's doc now says so rather than describing
itself as legacy.

proposeSigning gains an optional `key`, for the one caller that cannot be asked
which key the room signs with because establishing that is its whole job. It is
honoured only if this device actually holds a share of it, so naming a ceremony
cannot talk a session into signing with material it has not got.

## Everything signs as the room, not as the group's root key

unsignedEventOf and advance now build from SharedKeyDerivation.derive at the
room's path instead of TweakCache.create on the bare threshold key. Both halves
had to move together: a signature aggregates against whatever the cache carries,
so the cache and the author on the event have to be the same derivation or
nothing verifies.

Since marmotGroupId(K, path) *is* derive(K, path).hex, the pubkey on every event
a room signs -- dialect, artifact, chapter, key state -- is now that room's id.
A reader checking one needs no lookup at all: the key they expect is the id of
the room they found it in. marmotGroupId's doc now carries that second meaning,
and there is deliberately no second name for the value; "the room's id" and "the
key it signs as" are one function because they are one key.

The path is resolved by FrostSigningManager.signingPath and never taken from a
proposal, because it decides which key the group signs as -- a proposer able to
choose it could have every signer put their share behind an author of the
proposer's choosing. Three candidates in descending order of knowledge (the
room's GroupKeyState, the path in its MIP-01 description, the app's default),
and one is accepted only if walking it reaches the room's id, which makes the
resolution self-checking rather than trusting. acceptProposal runs the same
resolution independently on every device.

Null is a real answer, not a failure: completedKey will still find a key for a
room that was never derived from it -- a ceremony held in that very room, the
fallback kept for rooms the app no longer makes -- and such a room has no key of
its own to sign as, so it signs as the threshold key, which is what it always
did.

## What a receiver now checks

GroupKeyStateManager.stateFrom asks two independent questions, and a state has
to answer both:

  - Is it true? The room's id is the key derived at the path, so a state that
    does not rederive its own room names a key the room was not made from.
    Unchanged, and still the half that safety rests on. It knows nothing about
    who is speaking, and that is deliberate: a member with no share can state a
    true state and it is still true.

  - Did the group say it? GroupKeyStateEvent.isSignedByGroup: the author must be
    the key the content walks to at the path in the tags, the id must hash the
    fields sitting next to it, and the signature must verify. Since that walk is
    the room's id, a passing state is signed by the room it is about.

The second does not make a state truer -- the derivation already settled truth.
It makes the record of what a room signs with a thing a quorum agreed to. The
practical effect is that a true state nobody signed is now refused, which is the
behaviour change worth knowing about: an unsigned 30326 from an older client is
stored as an inner event and dropped as a state.

## Restart safety, and a nullable column

FrostSigningSession gains derivationPath, and the database goes to v9 on an
auto-migration. It is an input and is stored for the same reason nonceRandom is:
the cache is rebuilt on every pass of advance, and a session that resolved a
different path after a restart would regenerate a different nonce from the same
seed -- publishing a partial signature against an aggregate nobody else
computed.

Nullable, meaning no derivation at all: the untweaked threshold key. That is
both the honest answer for a room not derived from the key and what sessions
predating the column read back as, so a session caught mid-flight by the
migration finishes under the key it began under rather than switching between
two of its own rounds.

SharedKeyDerivation.derive now takes its key back out of the cache rather than
from the point, so an empty path is a real answer equal to what a session
created from that cache signs against. No behaviour changes for a non-empty
path, where the walk overwrites it on the first step.

## One place that files a key state

ChatMessage.applyInnerEvent records it, which it must: the signed 30326 reaches
every device through applySignedEvent, and the branch there previously returned
null and dropped it. The now-duplicate dispatch in NostrDao is removed, so the
locally applied signature and any wire-borne 30326 take the identical path.
Still no chat line -- standing state, and the session already wrote the
transcript of it happening.

## Elsewhere

DkgRepository.announceGroupKeyState becomes proposeGroupKeyState, taking the
room and returning the signing session rather than the state, since the state is
not what the call produces any more. DkgRitualViewModel calls it after members
are added, unchanged and for the unchanged reason: adding them commits a new
epoch, and a proposal published before it reaches nobody who could sign it.

FrostSigningScreen describes a key-state proposal as the group's shared key with
its path and ceremony, rather than "Event of kind 30326" -- a member deciding
whether to sign should be shown the thing.

## Tests: 217, 0 failures

  - GroupKeyStateTest is rewritten around real quorum signatures from
    Frost.trustedDealerKeygen. New: a true state nobody signed is dropped, a
    member's own signature over one is dropped, one group signing about another
    group's key is dropped, a state edited after signing is dropped, a state
    signed at the wrong path is dropped, and the room signs as its own id.
  - SignedArtifactTest pins that an artifact's author is the room it was signed
    in, and explicitly not the group's root key.
  - FrostSigningRoundTest runs both rounds against the tweaked cache now, which
    is the part most likely to be silently miswired -- a badly built cache
    produces a signature that simply fails to verify, on every device, quietly.
  - SharedKeyDerivationTest pins the migration contract: a walk of no steps
    lands on the threshold key, and a null derivationPath reads back as that
    empty walk rather than as the default path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 03:18:58 +02:00

168 KiB