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>
This commit is contained in:
Kgothatso Ngako
2026-09-06 03:18:58 +02:00
parent 9c50b4fcee
commit a8f6638325
22 changed files with 6342 additions and 288 deletions

View File

@@ -51,10 +51,23 @@ class FrostSigningRoundTest {
threshold = threshold
)
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
/**
* The room the group signs in, derived at the admin path.
*
* The cache carries the path's tweaks, and every call below is given it
* rather than a bare `TweakCache.create` -- because that is what the manager
* does, and because a tweaked cache is precisely the part most likely to be
* wired up wrong without saying so.
*/
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
/** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
private val tweakCache: TweakCache = room.cache
/** The group's nostr identity: the room's own id, which is what it signs as. */
private val groupPubKey = room.hex
/** The 32 bytes actually signed — a nostr event id, exactly as the manager computes it. */
private fun eventId(content: String): String = EventHasher.hashId(
@@ -116,7 +129,7 @@ class FrostSigningRoundTest {
hash = id.hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"the aggregated signature must verify against the group's x-only key"
"the aggregated signature must verify against the room's own key"
)
}
@@ -336,10 +349,15 @@ class FrostSigningCompletionTest {
threshold = 2
)
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
private val tweakCache: TweakCache = room.cache
/** The group's nostr identity, exactly as `unsignedEventOf` derives it. */
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
private val groupPubKey = room.hex
/** The event the group is asked to sign, built the way the manager builds it. */
private val unsignedEvent = Event(
@@ -406,6 +424,7 @@ class FrostSigningCompletionTest {
threshold = 2,
participantCount = 3,
signerId = 2,
derivationPath = SharedKeyDerivation.formatPath(),
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64),

View File

@@ -1,41 +1,75 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.secp256k1.Hex
import fr.acinq.bitcoin.XonlyPublicKey
import fr.acinq.bitcoin.crypto.frost.Frost
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
import fr.acinq.bitcoin.crypto.frost.SecretNonce
import fr.acinq.bitcoin.crypto.frost.Session
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
* What a room's key-state announcement is allowed to convince a member of.
* What a room's key state is allowed to convince a member of.
*
* The announcement is made by the coordinator, and the coordinator is untrusted
* by construction -- the same assumption every other part of the ceremony is
* written under. So the interesting cases here are all the ones where a state
* is *wrong*: a member who acts on a state naming a key their room was not made
* from signs with a share that cannot aggregate, or worse, treats a key the
* group does not hold as the key the group holds.
* Two independent things have to hold, and the tests come in those two halves.
*
* A state has to be *true*: the room's id is the threshold key derived at the
* path, so a state that does not rederive its own room is naming a key the room
* was not made from. A member who acts on one signs with a share that cannot
* aggregate, or worse, treats a key the group does not hold as the key the group
* holds. That check knows nothing about who is speaking and never did.
*
* And a state has to be the *group's*: it is now produced by a FROST signing
* session rather than announced by whoever made the room, so it carries a
* signature no single member can make. That does not make a state truer -- the
* derivation already settled truth -- it makes the first thing on a group's
* record something the group agreed to.
*
* `GroupKeyStateManager` needs a database and so cannot be stood up here. What
* can be is the check it defers to, which is where the whole trust model lives.
* can be is the deciding it defers to, which is where both halves live.
*/
class GroupKeyStateTest {
/** Stands in for a ceremony's output. Any valid point will do. */
private val thresholdPublicKey = PrivateKey(
Hex.decode("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
).publicKey().value.toHex()
private val participants = 3
private val threshold = 2
/** A second group's key, for the states that name the wrong one. */
private val otherKey = PrivateKey(
Hex.decode("2bada550000000000000000000000000000000000000000000000000000000b2")
).publicKey().value.toHex()
/** Stands in for a completed ceremony. Any valid key will do. */
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
),
nParticipants = participants,
threshold = threshold
)
/** A second group, entirely: its own key, its own shares, its own quorum. */
private val otherKeyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("2bada550000000000000000000000000000000000000000000000000000000b2")
),
nParticipants = participants,
threshold = threshold
)
private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex()
/** The other group's key, for the states that name the wrong one. */
private val otherKey = otherKeyMaterial.thresholdPublicKey.value.toHex()
private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
@@ -61,9 +95,9 @@ class GroupKeyStateTest {
@Test
fun `a state naming another group's key does not verify`() {
// The attack this is here for: a coordinator pointing the room at a key
// the group never made, so that everything signed in it is signed by
// whoever holds that key instead.
// The attack this is here for: a room pointed at a key the group never
// made, so that everything signed in it is signed by whoever holds that
// key instead.
assertFalse(state(thresholdPublicKey = otherKey).verifies())
}
@@ -89,7 +123,7 @@ class GroupKeyStateTest {
}
@Test
fun `the tags a state is announced on read back as they were written`() {
fun `the tags a state is proposed on read back as they were written`() {
val tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
@@ -136,79 +170,272 @@ class GroupKeyStateTest {
)
}
// ---- The announcement as it actually arrives -------------------------
// ---- The state as the group actually makes one -----------------------
//
// Everything above checks the verdict on a state already assembled. These
// check the assembling: a real Event, with the tags and content an
// announcement is carried on, through the function the inbound path calls.
// check the assembling: a real Event, signed by a real quorum against real
// FROST, through the function the inbound path calls.
private fun announcement(
/**
* A quorum of [material]'s group signing [eventId], in the manager's order.
*
* The same shape `FrostSigningManager.advance` runs -- nonces, a signer set,
* partial signatures, an aggregate -- because a signature assembled any
* other way would not be evidence that this check accepts the ones the app
* produces.
*/
private fun groupSignature(material: KeyMaterial, eventId: String): String =
groupSignatureAt(material, path, eventId)
private fun groupSignatureAt(
material: KeyMaterial,
path: List<Long>,
eventId: String
): String {
val cache = identityOf(material, path).cache
val message = ByteVector(eventId.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
SecretNonce.generate(
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
secretShare = material.secretShares[signerId],
publicShare = material.publicShares[signerId],
tweakedThresholdPublicKey = cache.tweakedPublicKey,
message = message,
extraInput = null
)
}
val signingSession = Session.create(
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { material.publicShares[it] },
nParticipants = participants,
threshold = threshold,
tweakCache = cache,
message = message
)
val partials = signerIds.mapIndexed { position, signerId ->
signingSession.sign(
nonces[position].first,
material.secretShares[signerId],
signerId.toUInt()
).right!!
}
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
}
/**
* A group's room at [path]: the cache a signature is made with and the key it
* verifies against, which is also the room's own id.
*/
private fun identityOf(
material: KeyMaterial,
path: List<Long> = this.path
): SharedKeyDerivation.Derived =
SharedKeyDerivation.derive(material.thresholdPublicKey.value.toHex(), path)
/**
* The event a proposal carries: the key state re-authored under the room's
* key, exactly as `FrostSigningManager.unsignedEventOf` does it.
*/
private fun unsignedKeyState(
content: String = thresholdPublicKey,
tags: Array<Array<String>> = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path),
pubKey: String = "c00rd1na70r",
createdAt: Long = 1_700_000_000
) = Event(
id = "an-id",
pubKey = pubKey,
createdAt = createdAt,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = content,
sig = ""
createdAt: Long = 1_700_000_000,
author: KeyMaterial = keyMaterial
): Event {
val groupPubKey = identityOf(author).hex
return Event(
id = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = createdAt,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = content
),
pubKey = groupPubKey,
createdAt = createdAt,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = content,
sig = ""
)
}
/**
* A finished key state, through the same call that hands a completed session
* its event -- so what these tests judge is what a device actually applies.
*/
private fun signedKeyState(
content: String = thresholdPublicKey,
tags: Array<Array<String>> = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path),
createdAt: Long = 1_700_000_000,
author: KeyMaterial = keyMaterial,
signer: KeyMaterial = author
): Event {
val unsigned = unsignedKeyState(content, tags, createdAt, author)
return FrostSigningManager.signedEvent(
session = sessionOver(unsigned),
signature = groupSignature(signer, unsigned.id)
)
}
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = chatRoomId,
coordinatorPublicKey = "c00rd1na70r",
userPublicKey = "c00rd1na70r",
dkgSessionId = "ceremony-1",
threshold = threshold,
participantCount = participants,
signerId = 0,
derivationPath = SharedKeyDerivation.formatPath(path),
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64)
)
@Test
fun `an announcement of the room it arrives in is taken`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, announcement())
fun `a state the group signed about the room it arrives in is taken`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState())
assertEquals(chatRoomId, state?.chatRoomId)
assertEquals("ceremony-1", state?.dkgSessionId)
assertEquals(thresholdPublicKey, state?.thresholdPublicKey)
assertEquals("m/9420/0/0", state?.derivationPath)
// Attribution and ordering come off the event, not off the clock.
assertEquals("c00rd1na70r", state?.announcedBy)
// Attribution and ordering come off the event, not off the clock. The
// author is the room itself now, which is the whole point: nobody in
// particular said this.
assertEquals(chatRoomId, state?.announcedBy)
assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt)
}
@Test
fun `an announcement naming another group's key is dropped`() {
// The one that matters: a coordinator pointing the room at a key the
// group never made. Everything else here is malformed input; this is
// well-formed input that lies.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = otherKey)))
fun `a true state nobody signed is dropped`() {
// The change this file exists to pin down. What is being refused here is
// not a lie -- this state is perfectly true and rederives its own room --
// it is one member deciding alone what the group signs with. That used to
// be enough and is not any more.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, unsignedKeyState()))
}
@Test
fun `an announcement addressed to another room is dropped`() {
fun `a state a member signed for themselves is dropped`() {
// The obvious way around the check: sign a true state with your own nostr
// key and hope the author is not looked at. The author has to be the
// identity of the key in the content, and a member is not that.
val member = PrivateKey(
ByteVector32("3c0ffee0000000000000000000000000000000000000000000000000000000c3")
)
val memberPubKey = XonlyPublicKey(member.publicKey()).value.toHex()
val tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path)
val id = EventHasher.hashId(
pubKey = memberPubKey,
createdAt = 1_700_000_000,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = thresholdPublicKey
)
val signed = Event(
id = id,
pubKey = memberPubKey,
createdAt = 1_700_000_000,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = thresholdPublicKey,
sig = Nip01Crypto.sign(
data = id.hexToByteArray(),
privKey = member.value.toByteArray()
).toHex()
)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signed))
}
@Test
fun `a state one group signed about another group's key is dropped`() {
// Only a group may say what its own key is. This one is signed, by a real
// quorum, and still refused: the signature is not by the key it names.
assertNull(
GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(author = otherKeyMaterial))
)
}
@Test
fun `a state edited after the group signed it is dropped`() {
// A signature covers the id, and the id is the hash of the fields. Swap
// a field and the two stop agreeing, which is what the id check is for.
val signed = signedKeyState()
val tampered = Event(
id = signed.id,
pubKey = signed.pubKey,
createdAt = signed.createdAt,
kind = signed.kind,
tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-2", path),
content = signed.content,
sig = signed.sig
)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, tampered))
}
@Test
fun `a state naming another group's key is dropped`() {
// Well-formed input that lies. Refused by the derivation rather than by
// the signature -- the group here is signing about its own key, it is
// simply not the key this room was made from.
val siblingRoom = SharedKeyDerivation.marmotGroupId(otherKey, path)
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
signedKeyState(
content = otherKey,
tags = GroupKeyStateEvent.assembleTags(siblingRoom, "ceremony-1", path),
author = otherKeyMaterial
)
)
)
}
@Test
fun `a state addressed to another room is dropped`() {
val elsewhere = GroupKeyStateEvent.assembleTags(otherKey, "ceremony-1", path)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = elsewhere)))
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = elsewhere)))
}
@Test
fun `an announcement missing any of what it has to say is dropped`() {
fun `a state missing any of what it has to say is dropped`() {
val full = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path)
// No ceremony to reach a share through.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
announcement(tags = full.filterNot { it[0] == "frost_key" }.toTypedArray())
signedKeyState(tags = full.filterNot { it[0] == "frost_key" }.toTypedArray())
)
)
// No path, so nothing to rebuild a TweakCache from.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
announcement(tags = full.filterNot { it[0] == "frost_path" }.toTypedArray())
signedKeyState(tags = full.filterNot { it[0] == "frost_path" }.toTypedArray())
)
)
// No key.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = "")))
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(content = "")))
}
@Test
fun `an announcement carrying no d tag is judged on its derivation alone`() {
fun `a state carrying no d tag is judged on its derivation alone`() {
// The d tag is a convenience for a reader holding the event on its own.
// Dropping it loses nothing that matters, because the room it arrived in
// plus the derivation still settle the question.
@@ -219,7 +446,7 @@ class GroupKeyStateTest {
assertEquals(
chatRoomId,
GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = undirected))?.chatRoomId
GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected))?.chatRoomId
)
}
@@ -242,4 +469,53 @@ class GroupKeyStateTest {
// else defers to, and it should not be the thing that trusts its input.
assertFalse(state(derivationPath = "m/4294967296/0/0").verifies())
}
@Test
fun `the room signs as its own id`() {
// The whole point of deriving at the room's path. The author of a state
// is the room it belongs to, so a reader checking one needs no lookup:
// the key they expect is the id of the room they found it in.
assertEquals(chatRoomId, signedKeyState().pubKey)
// And it verifies as that key, which is the half a signature makes true
// rather than merely stated.
val signed = signedKeyState()
assertTrue(
Nip01Crypto.verify(
signature = signed.sig.hexToByteArray(),
hash = signed.id.hexToByteArray(),
pubKey = chatRoomId.hexToByteArray()
)
)
}
@Test
fun `a state signed at the wrong path is dropped`() {
// The path decides which key the group signs as, so a session that walked
// a different one produces an author this room does not answer to -- even
// with the right group, the right key and a real quorum behind it.
val sibling = listOf(9420L, 0L, 1L)
val siblingIdentity = identityOf(keyMaterial, sibling)
val tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path)
val id = EventHasher.hashId(
pubKey = siblingIdentity.hex,
createdAt = 1_700_000_000,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = thresholdPublicKey
)
val signed = Event(
id = id,
pubKey = siblingIdentity.hex,
createdAt = 1_700_000_000,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = thresholdPublicKey,
sig = groupSignatureAt(keyMaterial, sibling, id)
)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signed))
}
}

View File

@@ -1,12 +1,16 @@
package press.mantra.compose.managers
import press.mantra.compose.extensions.toHex
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.PublicKey
import fr.acinq.secp256k1.Hex
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
import press.mantra.compose.database.model.FrostSigningSession
/**
* The derivation every member's device has to agree on, run against real FROST
@@ -125,4 +129,50 @@ class SharedKeyDerivationTest {
assertEquals(derived.publicKey, derived.cache.tweakedPublicKey)
}
@Test
fun `a walk of no steps lands on the threshold key itself`() {
// What a signing session with no path signs as: a row written before that
// column existed, or a room that is not derived from the key at all. Both
// have to reach the untweaked key, or a session caught by the migration
// would change which key it signs as between two of its own rounds.
val nowhere = SharedKeyDerivation.derive(thresholdPublicKey, emptyList())
assertEquals(
PublicKey(ByteVector(thresholdPublicKey.hexToByteArray())).xOnly().value.toHex(),
nowhere.hex
)
assertEquals(nowhere.publicKey, nowhere.cache.tweakedPublicKey)
// And it is not any room: the admin path walks somewhere else entirely.
assertNotEquals(nowhere.hex, SharedKeyDerivation.marmotGroupId(thresholdPublicKey))
}
@Test
fun `a session with no derivation path walks nowhere`() {
// The column is nullable and null means "no derivation", which has to read
// back as the empty walk above rather than as the default path.
assertEquals(emptyList(), session(derivationPath = null).pathIndices())
assertEquals(
SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
session(derivationPath = "m/9420/0/0").pathIndices()
)
// An unwalkable string is not a path anybody signed at either.
assertEquals(emptyList(), session(derivationPath = "m/9420'/0/0").pathIndices())
}
private fun session(derivationPath: String?) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = "room",
coordinatorPublicKey = "c".repeat(64),
userPublicKey = "u".repeat(64),
dkgSessionId = "k".repeat(64),
threshold = 2,
participantCount = 3,
signerId = 0,
derivationPath = derivationPath,
unsignedEventJson = "{}",
eventId = "e".repeat(64),
nonceRandom = "f".repeat(64)
)
}

View File

@@ -40,7 +40,6 @@ import press.mantra.compose.nostr.nip30303.ArtifactEvent
class SignedArtifactTest {
private val participants = 3
private val threshold = 2
private val chatRoomId = "room"
/** The member who filled in the form. Nothing they own should end up on the row. */
private val proposer = "9".repeat(64)
@@ -55,10 +54,21 @@ class SignedArtifactTest {
threshold = threshold
)
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
/**
* The room the group signs in: derived from its key at the admin path, which
* is what makes the room's id and the key it signs as one value.
*/
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
/** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
private val tweakCache: TweakCache = room.cache
/** The group's nostr identity here, which is also [chatRoomId]. */
private val groupPubKey = room.hex
private val chatRoomId = room.hex
private fun proposalTemplate(versionLabel: String = "1.0") = ArtifactEvent.build(
name = "In Detention",
@@ -99,6 +109,7 @@ class SignedArtifactTest {
threshold = threshold,
participantCount = participants,
signerId = 0,
derivationPath = SharedKeyDerivation.formatPath(),
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64)
@@ -160,6 +171,22 @@ class SignedArtifactTest {
assertNotEquals(proposer, artifact.publicKey)
}
@Test
fun `the group it is authored by is the room it was signed in`() {
// Signing runs at the room's derivation path, so the author is the room's
// own id rather than the bare threshold key. That is what lets anybody
// holding the row check it without being told which key to expect -- and
// it is why the path a session signs at cannot come from the proposer.
val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId)
assertEquals(chatRoomId, artifact?.publicKey)
assertNotEquals(
keyMaterial.thresholdPublicKey.xOnly().value.toHex(),
artifact?.publicKey,
"an artifact must be signed by the room's key, not by the group's root key"
)
}
@Test
fun `the row's id is the id the group put its signature to`() {
// Every device builds this row from the same signed event, so the id has