Files
mantra-kmp/docs/shared-key-ceremony.md
Kgothatso Ngako 45cc80b538
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
feat(marmot): put a # in front of every group's name, and retire (#admins)
A device's room list holds two unrelated kinds of room and nothing on a row said
which. A NIP-17 room is a conversation between the people in it. A Marmot room is
a *group* -- an id its key derives, a membership baked into an MLS tree, admins
who can act for it, a signature anyone holding the id can check -- and the two
behave differently enough that guessing is a mistake.

`"Ekklesia (#admins)"` was an attempt at saying so, and it marked the wrong half.
Only the admin room got it; a subgroup got no marker at all, so as soon as a group
had one child, half the Marmot rooms on the device were unmarked. It also sorted
nowhere near the group it belonged to, and a truncated row drops a trailing suffix
first -- so the marker was missing exactly where the list is crowded enough to
need it.

**The rule is `MarmotGroupName.of`, and it runs where a room is minted rather than
where it is drawn.** The name is baked into the epoch-0 `MarmotGroupData` every
member is welcomed with, so a `#` added at display time would be a name this
device alone could see. `#Ekklesia` marks both kinds of group room, and marks them
at the front.

**Three mints, because there are three ways a Marmot room comes into existence.**
`MarmotGroupCreation.create` is the funnel for two of them -- the admin room a
group opens after its ceremony, and a subgroup -- and normalising there means
neither caller has to remember. The third, `SelectChatRoomTypeViewModel`'s
convenient room, has a random id rather than a derived one, so it has no key state
to adopt and no admin set to bake in and does not pass through that funnel; it
applies the rule itself.

**Idempotence is load-bearing, not tidiness.** A subgroup's name is derived twice
from the same bare ceremony-room subject, by two callers that never see each
other: `SubgroupManager.proposeBirthCertificate` normalises the name the parent's
quorum is asked to sign, and `MarmotGroupCreation` normalises the name the room
carries. Those two have to be the same string, or the subgroup is not called what
its parent certified -- and a certificate is a signature over the name, so a
verifier comparing them would see a real mismatch. `of` being idempotent is what
makes them agree by construction rather than by both sites being kept in step.

**The ceremony room keeps the bare name.** It is a NIP-17 room -- where a subgroup
is made, not the subgroup -- and prefixing it too produced two identically-named
rows, which spends the mark to say nothing. `Translators` (the ceremony) now sits
beside `#Translators` (the group it stood up), which is the distinction the `#`
exists to draw. Its subject is trimmed, so the bare name and the two normalised
ones cannot differ by whitespace.

**The `#` is drawn beside the name field, not pushed into its state.** `name` in
`SelectSubgroupAdminsViewModel` stays bare and the M3 `prefix` slot shows the
convention, because normalising on every keystroke moves the caret out from under
somebody halfway through a word. The coordinator still reads the name they are
about to get.

Four strings lose the old name -- "Create the #admins group" becomes "Create the
admin room", and the three about what "the #admins room" will sign with now say
"the admin room". Their keys are renamed with them, since the keys in this
catalogue are derived from the text. Around twenty comments, two screen previews
and seven test fixtures follow.

Docs: the ceremony note states the convention and what it replaces, and the
subgroups note's name-field section is rewritten -- it had been arguing from the
`"${parent.subject} (#admins)"` synthesis that no longer exists.
`docs/mls-skipped-keys.md` keeps its `"Frosty (#admins)"`: that is a captured
debugging log, and rewriting it would falsify a record.

Three tests. `MarmotGroupNameTest` pins the rule, idempotence included.
`MarmotGroupCreationJvmTest` pins the funnel -- a bare name in, `#Ekklesia` on both
the room row this device draws and the group data every other member reads.
`SubgroupManagerJvmTest` pins the pair that has to agree, by reading the proposed
event's tags back out of the signing session: the name the parent is asked to sign
is the name `MarmotGroupCreation` will give the room. That last one needed the
signable-parent fixture to seed host keys, since a ceremony's signer ids are
derived from them rather than stored.

**Rooms that already exist keep their names.** The name lives in the epoch-0 group
context, so renaming one is an MLS commit every member has to process -- a
different change from a naming convention, and not made here.

403 common tests, 726 jvm tests, `m3Audit` meets every budget with 0 title-case
strings and 0 dp literals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:41:30 +02:00

8.5 KiB

The shared key ceremony

A group creates a t-of-n FROST key by running ChillDKG over its NIP-17 chat. No trusted dealer, no single device ever holding the whole key.

Implemented in ChillDkgRitualManager, on fr.acinq.bitcoin.crypto.dkg.chill.ChillDKG.

Shape

The group is the participant set, the member who opens the ceremony coordinates it, and every protocol message travels as a gift-wrapped rumor on the same NIP-17 pipeline chat messages already use — so there is no second transport to operate.

The coordinator is a participant too, and ChillDKG treats it as untrusted: it relays and aggregates but cannot learn secrets or bias the key. Being the room's creator buys it no authority, only work.

kind from carries
30310 coordinator proposal — "let's make a t-of-n key"
30311 participant host public key
30312 participant pmsg1, this device's contribution
30313 coordinator cmsg1, everyone's contributions combined
30314 participant CertEq signature confirming the combined result
30315 coordinator cmsg2, the success certificate
30316 anyone failure — abort and blame

Every step is a pure function of inputs the device has already stored, so there is no long-lived in-memory session to lose. Each inbound message is persisted and then the ritual is asked whether it can move; if the app dies mid-round it resumes on the next message. DkgSession deliberately stores inputs — the randomness and the received messages — rather than protocol state, which is what makes that work.

A ceremony cannot finish until every member takes part. That is unusual for a chat feature and drives most of the UI: the progress ladder names who it is waiting on rather than showing a count, because "2 of 3" does not tell anyone whose door to knock on.

Nothing publishes without approval

The ritual is driven by arriving messages, which originally meant a relay delivering an event to a phone in someone's pocket was enough to enrol its owner in a group's permanent signing quorum. acceptProposal published the host key on arrival; rounds 1 and 2 followed automatically.

It now publishes nothing on this device's behalf until its owner agrees, at three separate gates:

step publishes why it is its own decision
HOST_KEY the host public key joins the ceremony and fixes n. Joining then going quiet holds it open for everyone
ROUND_1 the key contribution the member's secret material starts shaping a key they must help sign with
ROUND_2 the CertEq signature a real check: it is what stops a coordinator substituting a key the members never contributed to

The coordinator's two aggregations are not gated. They relay other members' already-published messages and disclose nothing of the coordinator's own, so an approval there would stall the whole group on one person's attention without protecting anybody.

The member who opens a ceremony is auto-approved for the host key alone — starting one is already the act of agreeing to be in it — and is still asked for rounds 1 and 2.

Each gate returns rather than throwing. The ritual is not failing, it is waiting on a person; everything received stays stored and it resumes on approval.

pendingApproval must mirror the gates in advance exactly. If they drift, the screen offers an approval that does nothing, or offers none while the ritual sits still. Neither produces an error.

Approvals are recorded as three nullable timestamps on DkgSession, plus approvalRequestedThrough so the chat line asking for each is written once.

Faults are values, not exceptions

ChillDKG reports a faulty participant in the ChilldkgFault field of each result, because a faulty participant is a normal outcome of a DKG rather than a bug.

This ritual has one response to all of them — the key is unusable, the session dies, the group is told — so raiseIfFaulty turns them into an exception carrying the culprit and lets them join advance's single failure path. The gain is the failure text: "ChillDKG round 2 failed: a participant is faulty (participant 3)" rather than whatever e.message happened to hold. On a failed DKG, which participant to blame is the only actionable thing there is.

The transcript

Every protocol message becomes a line in the group's chat naming the member whose device sent it, plus the three that bracket them: started, complete, abandoned.

These rows are not anybody's words: no giftWrapPayloadId, no event behind them, nothing sent to say them. Each device writes its own from messages it already received, so they cost no traffic and cannot disagree with the ritual they describe. They render as system lines rather than bubbles — attributing "a shared key ceremony started" to the coordinator would read as something they said.

Wording describes what a step accomplishes, not what it is called. "sent their contribution to the key" is useful in a group chat; "sent pmsg1" is not, and the protocol names are on the shared-key screen for anyone who wants them.

Idempotency is by construction, not de-duplication. ChatMessage has no key to make a second insert a no-op — its id is autogenerated — while ritual messages arrive repeatedly: relays redeliver, and replayStoredMessages feeds the whole backlog through record() again on every resume. So every announce is guarded by reading the row it is about to write over. DkgParticipantMessage absorbs the redelivery itself, being keyed on (sessionId, participantPublicKey, kind); a chat row cannot.

Request lines carry their step in the message type — one type per step, not one type for all three. The type is the only thing a transcript keeps: a line drawn days later has no session to ask what was being requested. Sharing one type left every request wearing the same icon.

Whether a request was answered is read from the transcript rather than the session: approving is the only thing that causes the step to be published, and publishing writes an authored line. That keeps a room that has run more than one ceremony correct, since ChatMessage has no session id to disambiguate with.

Ordering

ChillDKG has no session-params object to agree on out of band. Every step takes the host public keys and the threshold and hashes them into the session identity, so a group that orders its participants differently on different devices does not get a weaker key — it gets no key.

Each device derives that order independently by sorting the host keys bytewise, and orders each round's messages by their sender's host key to match. Sorting is the only ordering every device can arrive at without being told. Nothing in the protocol checks this, so ChillDkgRitualOrderingTest does.

After it completes

The group has a threshold public key; each device keeps its own share, restorable from that member's wallet backup and nobody else's.

From there the coordinator can create the group's admin room — a Marmot group whose id is derived from the shared key. See shared-key-derivation.md for how, and marmot-membership.md for how members are added to it.

It is named # and then the group's name — #Ekklesia for a group called Ekklesia. Every Marmot room on a device carries that mark and no NIP-17 room does, which is the only thing on a room list that says which of the two a row is. The rule is one function, MarmotGroupName.of, applied where a room is minted rather than where it is drawn: the name is baked into the epoch-0 MarmotGroupData every member is welcomed with, so a name added at display time would be a name this device alone could see. It replaces Ekklesia (#admins), which marked only the admin room, left every subgroup unmarked, and sorted nowhere near the group it belonged to.

Known gaps

  • No ceremony has been run on a physical device.
  • A member invited to the admin room whose Welcome never goes out is indistinguishable, from the coordinator's side, from one who joined.
  • The ritual's request chat lines are 3n + 4 per ceremony — 19 lines for five members. Compact, but they dominate a transcript while a ceremony runs.