Merge branch 'mantra' into claude/artifact-frost-signing-proposal-a414f6

This commit is contained in:
Kgothatso Ngako
2026-09-06 01:38:08 +02:00
6 changed files with 486 additions and 64 deletions

View File

@@ -0,0 +1,131 @@
package press.mantra.compose.database.model
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant
/**
* When a request line in a transcript stops asking for something.
*
* The transcript renders a request with a tint and a "Review" affordance, and
* that is a promise: tapping it leads to a decision still there to be made.
* Keeping the promise means knowing when the decision has gone, and the rows are
* all there is to know it from -- a line rendered days later has no session to
* ask, and the room may have signed several things since.
*
* Two ways for a request to be over, and they are not the same. Answering it
* leaves a line of the reader's own and earns the tick. A session ending
* underneath it leaves nothing of theirs at all: a member who declined published
* nothing, and a quorum that signed without them wanted nothing. Both must drop
* the summons; neither may claim the member signed.
*/
class TranscriptRequestStateTest {
private val user = "u".repeat(64)
private val other = "o".repeat(64)
private var lastId = 0L
/** One transcript row, with only the four fields either rule reads. */
private fun line(
type: String,
at: Long,
sender: String = user
) = ChatMessage(
id = ++lastId,
senderPublicKey = sender,
isUserMessage = sender == user,
giftWrapPayloadId = null,
marmotGroupEventId = null,
marmotInnerEventId = null,
chatRoomId = "room",
content = "",
messageType = type,
createdAt = Instant.fromEpochSeconds(at)
)
@Test
fun `a signing request nobody has acted on is still asking`() {
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
val transcript = listOf(line(ChatMessage.TYPE_FROST_STARTED, at = 9, sender = other), request)
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
}
@Test
fun `publishing the nonce answers the request that asked for it`() {
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11))
assertEquals(setOf(request.id), ChatMessage.answeredRequests(transcript))
}
@Test
fun `somebody else's nonce answers nothing`() {
// The fulfilment has to be this device's own: a transcript is full of other
// members taking the step this reader has yet to take.
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11, sender = other))
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
}
@Test
fun `declining settles the request without claiming it was signed`() {
// Declining publishes nothing, so there is no fulfilment to find. The
// failure the refusal writes is the only trace, and it has to be enough --
// otherwise the line goes on offering a decision already made.
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_FAILED, at = 11))
assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript))
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
}
@Test
fun `a group that signs without this member settles their request`() {
// A t-of-n key does not need everybody. Nothing of this member's is in the
// signature and nothing of theirs was ever published, so answered stays
// empty -- but there is no longer anything for them to decide.
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_COMPLETE, at = 12, sender = other))
assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript))
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
}
@Test
fun `an earlier session's ending does not close a later request`() {
// Rooms sign more than once, and the previous session's last line sits
// above this one's first.
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 20)
val transcript = listOf(line(ChatMessage.TYPE_FROST_COMPLETE, at = 9, sender = other), request)
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
}
@Test
fun `a ceremony step is settled by nothing`() {
// Signing is the one thing a member can refuse, so it is the only place a
// request can be over without them having answered it. A ceremony step is
// either taken or still waited on, and reading either ending as the end of
// one would drop a summons the ritual is still stalled on.
val request = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 10)
val transcript = listOf(
request,
line(ChatMessage.TYPE_FROST_FAILED, at = 11),
line(ChatMessage.TYPE_DKG_FAILED, at = 12, sender = other)
)
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
}
@Test
fun `each ceremony step is answered only by its own`() {
val hostKey = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY, at = 10)
val roundOne = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 12)
val transcript = listOf(hostKey, line(ChatMessage.TYPE_DKG_HOST_KEY, at = 11), roundOne)
assertEquals(setOf(hostKey.id), ChatMessage.answeredRequests(transcript))
}
}

View File

@@ -1,5 +1,6 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -17,8 +18,10 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.FrostSigningEvents
@@ -217,6 +220,39 @@ class FrostSigningSessionTest {
signerIds = signerIds
)
@Test
fun `a session waits on its owner until they answer`() {
val open = session(signerId = 2, signerIds = null)
assertTrue(FrostSigningManager.isAwaitingApproval(open))
assertFalse(
FrostSigningManager.isAwaitingApproval(
open.copy(signApprovedAt = Instant.fromEpochSeconds(1))
)
)
}
@Test
fun `a session that has settled asks its owner nothing`() {
val open = session(signerId = 2, signerIds = null)
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE)))
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED)))
}
@Test
fun `a signature the group already made asks its owner nothing either`() {
// A t-of-n key does not need everybody, so a quorum can finish while one
// member's phone is still in a pocket. The session stays at its opening
// stage on their device until it next advances, and offering them the
// decision in that window offers two bad answers: a nonce nobody is
// waiting for, or a refusal that abandons a signature that exists.
val signedWithoutThem = session(signerId = 2, signerIds = "0,1")
.copy(signature = "a".repeat(128))
assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem))
}
@Test
fun `a member left out of the signer set is not a signer`() {
assertTrue(session(signerId = 1, signerIds = "0,1").isSigner())
@@ -276,3 +312,175 @@ class FrostSigningSessionTest {
)
}
}
/**
* What a device needs on its row to finish a session it never took part in.
*
* `FrostSigningManager.advance` completes on an arrived signature ahead of the
* approval gate, and that hoist rests on one claim: closing a session needs
* nothing secret and nothing the member would have had to publish. Were it
* false -- were the aggregated nonce, the signer set or a share needed to check
* the result -- the gate would have to stay where it was, and a member the
* quorum did not need would be stuck being asked to sign something already
* signed.
*
* So the claim is spelled out here against a real 2-of-3 signature, from the
* row of the member who was left out of it.
*/
class FrostSigningCompletionTest {
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("2decade0000000000000000000000000000000000000000000000000000000b2")
),
nParticipants = 3,
threshold = 2
)
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
/** The group's nostr identity, exactly as `unsignedEventOf` derives it. */
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
/** The event the group is asked to sign, built the way the manager builds it. */
private val unsignedEvent = Event(
id = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = 1_700_000_000L,
kind = 1,
tags = arrayOf(),
content = "a dialect the group agreed on"
),
pubKey = groupPubKey,
createdAt = 1_700_000_000L,
kind = 1,
tags = arrayOf(),
content = "a dialect the group agreed on",
sig = ""
)
/** A real signature from members 0 and 1. Member 2 is not in it and never was. */
private val signature: String = run {
val message = ByteVector(unsignedEvent.id.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
SecretNonce.generate(
sessionRandom = ByteVector32("c".repeat(63) + "${signerId + 1}"),
secretShare = keyMaterial.secretShares[signerId],
publicShare = keyMaterial.publicShares[signerId],
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
message = message,
extraInput = null
)
}
val session = Session.create(
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
nParticipants = 3,
threshold = 2,
tweakCache = tweakCache,
message = message
)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
}
session.aggregateSigs(partials).right!!.toHex()
}
/**
* Member 2's row, as it stands when the signature reaches them: they never
* approved, so nothing of theirs was ever published, and the coordinator
* never named them. Every column the completion path reads is here; the ones
* it must not need are deliberately left null.
*/
private fun leftOutMemberSession(signature: String? = null) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = "room",
coordinatorPublicKey = "c".repeat(64),
userPublicKey = "u".repeat(64),
dkgSessionId = "k".repeat(64),
threshold = 2,
participantCount = 3,
signerId = 2,
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64),
aggregatedNonce = null,
signerIds = null,
signature = signature,
signApprovedAt = null
)
@Test
fun `a member who never took part can still check what the group signed`() {
val session = leftOutMemberSession(signature)
val signed = FrostSigningManager.signedEvent(session)!!
assertTrue(
Nip01Crypto.verify(
signature = signed.sig.hexToByteArray(),
hash = session.eventId.hexToByteArray(),
pubKey = signed.pubKey.hexToByteArray()
),
"completing must need only the row: the event, its id and the signature"
)
}
@Test
fun `the finished event is the one that was proposed, with a signature on it`() {
// Not rebuilt and not rehashed: the id a session is pinned to is the id
// the signature is over, so anything that changed here would produce an
// event whose signature verifies against nothing.
val signed = FrostSigningManager.signedEvent(leftOutMemberSession(signature))!!
assertEquals(unsignedEvent.id, signed.id)
assertEquals(unsignedEvent.pubKey, signed.pubKey)
assertEquals(unsignedEvent.createdAt, signed.createdAt)
assertEquals(unsignedEvent.kind, signed.kind)
assertEquals(unsignedEvent.content, signed.content)
assertEquals(signature, signed.sig)
}
@Test
fun `there is no finished event until the signature arrives`() {
assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession()))
}
@Test
fun `the arrived signature is what stops the session asking`() {
// The pair that matters to the screen and the transcript: the same row,
// before and after the group finished without this member.
assertTrue(FrostSigningManager.isAwaitingApproval(leftOutMemberSession()))
assertFalse(FrostSigningManager.isAwaitingApproval(leftOutMemberSession(signature)))
}
@Test
fun `a signature over a different event is refused`() {
// What the check is for. A coordinator passing off something else must not
// get it applied and announced as the group's, and the row is all there is
// to catch it with.
val other = leftOutMemberSession(signature).copy(
eventId = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = 1_700_000_000L,
kind = 1,
tags = arrayOf(),
content = "something else entirely"
)
)
val signed = FrostSigningManager.signedEvent(other)!!
assertFalse(
Nip01Crypto.verify(
signature = signed.sig.hexToByteArray(),
hash = other.eventId.hexToByteArray(),
pubKey = signed.pubKey.hexToByteArray()
)
)
}
}