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 ina909108, and changes the author of every event the group signs, including the artifacts of786c060. ## The key state is proposed, not announceda909108had 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:
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user