Merge branch 'mantra' into claude/artifact-frost-signing-proposal-a414f6
This commit is contained in:
@@ -241,6 +241,21 @@ data class ChatMessage(
|
||||
/** The signing lines that ask rather than report. */
|
||||
val FROST_REQUEST_TYPES = setOf(TYPE_FROST_APPROVAL_NEEDED)
|
||||
|
||||
/**
|
||||
* The signing lines that end a session, whichever way it went.
|
||||
*
|
||||
* A request usually stops being one by being answered, but that is not
|
||||
* the only way. A member who declines publishes nothing, and a quorum
|
||||
* that signs without them asks them for nothing further -- from the
|
||||
* transcript both look the same, nothing of the reader's own either
|
||||
* side of the request, so the session's own ending is the only thing
|
||||
* left to read it from.
|
||||
*
|
||||
* A ceremony has no equivalent because a ceremony step can only be
|
||||
* taken or waited for. Signing is the one thing a member can refuse.
|
||||
*/
|
||||
val FROST_SETTLEMENTS = setOf(TYPE_FROST_COMPLETE, TYPE_FROST_FAILED)
|
||||
|
||||
/** Every signing line, for rendering them as system lines rather than bubbles. */
|
||||
val FROST_TYPES = setOf(
|
||||
TYPE_FROST_STARTED,
|
||||
@@ -278,6 +293,49 @@ data class ChatMessage(
|
||||
TYPE_DKG_FAILED,
|
||||
)
|
||||
|
||||
/**
|
||||
* The request lines this device has since answered, by row id.
|
||||
*
|
||||
* A request is answered when the step it asked for has been published by
|
||||
* this device -- which is exactly what approving it does. The transcript
|
||||
* already records that as an authored line, so the answer is read from
|
||||
* the list rather than from the session, and it stays right for a room
|
||||
* that has run more than one ceremony.
|
||||
*/
|
||||
fun answeredRequests(messages: List<ChatMessage>): Set<Long> = messages
|
||||
.mapNotNull { request ->
|
||||
val published = (DKG_REQUEST_FULFILMENTS + FROST_REQUEST_FULFILMENTS)[request.messageType]
|
||||
?: return@mapNotNull null
|
||||
|
||||
val done = messages.any {
|
||||
it.messageType == published &&
|
||||
it.isUserMessage &&
|
||||
it.createdAt >= request.createdAt
|
||||
}
|
||||
|
||||
request.id.takeIf { done }
|
||||
}
|
||||
.toSet()
|
||||
|
||||
/**
|
||||
* The signing requests that are no longer open, by row id.
|
||||
*
|
||||
* Not the same thing as [answeredRequests], and deliberately kept apart
|
||||
* from it: these are requests the reader never answered, so they have
|
||||
* earned no tick and claiming otherwise would credit them with a
|
||||
* signature they refused or were never needed for. What they have run
|
||||
* out of is a decision to make -- see [FROST_SETTLEMENTS].
|
||||
*/
|
||||
fun settledRequests(messages: List<ChatMessage>): Set<Long> = messages
|
||||
.filter { request ->
|
||||
request.messageType in FROST_REQUEST_TYPES &&
|
||||
messages.any {
|
||||
it.messageType in FROST_SETTLEMENTS && it.createdAt >= request.createdAt
|
||||
}
|
||||
}
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
|
||||
/**
|
||||
* Files one direct message, from whichever side of it this device is on.
|
||||
*
|
||||
|
||||
@@ -464,6 +464,19 @@ object FrostSigningManager {
|
||||
?: return
|
||||
|
||||
try {
|
||||
// A signature the group has already made settles this session whether
|
||||
// or not its owner ever answered: a t-of-n key does not need everybody,
|
||||
// so a quorum can finish while one member's phone is still in a pocket.
|
||||
// Ahead of the gate below because that gate is about keeping this
|
||||
// device's own material off the wire, and finishing puts none of it
|
||||
// there -- while going on to ask would be asking for a decision that
|
||||
// can no longer change anything, and would let a late "don't sign"
|
||||
// abandon a signature that exists.
|
||||
if (session.signature != null) {
|
||||
complete(database, session)
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing of this device's own goes out before its owner has said so.
|
||||
// Returns rather than throws: the session is not failing, it is waiting
|
||||
// on a person, and everything received stays stored so it resumes the
|
||||
@@ -580,43 +593,7 @@ object FrostSigningManager {
|
||||
announceStep(database, session, FrostSigningEvents.SIGNATURE, session.userPublicKey)
|
||||
}
|
||||
|
||||
val signature = session.signature ?: return
|
||||
|
||||
// The payoff: a signature that verifies is one the group made, whoever
|
||||
// relayed it. Checking rather than trusting is what keeps a faulty or
|
||||
// dishonest coordinator from passing off something that will be
|
||||
// rejected by every relay it reaches.
|
||||
val signedEvent = signedEvent(session, signature)
|
||||
val verified = Nip01Crypto.verify(
|
||||
signature = signature.hexToByteArray(),
|
||||
hash = session.eventId.hexToByteArray(),
|
||||
pubKey = signedEvent.pubKey.hexToByteArray()
|
||||
)
|
||||
if (!verified) {
|
||||
throw IllegalStateException("The aggregated signature does not verify against ${session.eventId}")
|
||||
}
|
||||
|
||||
update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) }
|
||||
|
||||
// A signature exists to be used. Every device has the event and the
|
||||
// signature by now, so each applies the result itself rather than
|
||||
// waiting to be sent something it can already build -- the same
|
||||
// reasoning the transcript lines are written on. Nothing goes on the
|
||||
// wire: a signed event authored by the threshold key cannot travel as
|
||||
// an inner event anyway, because the outbound pipeline re-authors
|
||||
// rumors as their sender and would strip the group's signature off.
|
||||
applySignedEvent(database, session, signedEvent)
|
||||
|
||||
announce(
|
||||
database = database,
|
||||
session = session,
|
||||
messageType = ChatMessage.TYPE_FROST_COMPLETE,
|
||||
content = "The group signed the event. It took ${session.threshold} of " +
|
||||
"${session.participantCount} members.",
|
||||
actor = session.coordinatorPublicKey
|
||||
)
|
||||
|
||||
logger.i("Signing session $sessionId complete")
|
||||
complete(database, session)
|
||||
} catch (e: CancellationException) {
|
||||
// The sync was torn down mid-step, which says nothing about the session.
|
||||
throw e
|
||||
@@ -633,6 +610,54 @@ object FrostSigningManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the group's signature, uses the event, and closes the session.
|
||||
*
|
||||
* The payoff: a signature that verifies is one the group made, whoever
|
||||
* relayed it. Checking rather than trusting is what keeps a faulty or
|
||||
* dishonest coordinator from passing off something that will be rejected by
|
||||
* every relay it reaches.
|
||||
*
|
||||
* Reached only with the session still open -- [advance] returns above this
|
||||
* on a settled one -- so the milestone is written once by construction,
|
||||
* like every other line in the transcript.
|
||||
*/
|
||||
private suspend fun complete(database: MantraDatabase, session: FrostSigningSession) {
|
||||
val signature = session.signature ?: return
|
||||
|
||||
val signedEvent = signedEvent(session, signature)
|
||||
val verified = Nip01Crypto.verify(
|
||||
signature = signature.hexToByteArray(),
|
||||
hash = session.eventId.hexToByteArray(),
|
||||
pubKey = signedEvent.pubKey.hexToByteArray()
|
||||
)
|
||||
if (!verified) {
|
||||
throw IllegalStateException("The aggregated signature does not verify against ${session.eventId}")
|
||||
}
|
||||
|
||||
update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) }
|
||||
|
||||
// A signature exists to be used. Every device has the event and the
|
||||
// signature by now, so each applies the result itself rather than
|
||||
// waiting to be sent something it can already build -- the same
|
||||
// reasoning the transcript lines are written on. Nothing goes on the
|
||||
// wire: a signed event authored by the threshold key cannot travel as
|
||||
// an inner event anyway, because the outbound pipeline re-authors
|
||||
// rumors as their sender and would strip the group's signature off.
|
||||
applySignedEvent(database, session, signedEvent)
|
||||
|
||||
announce(
|
||||
database = database,
|
||||
session = session,
|
||||
messageType = ChatMessage.TYPE_FROST_COMPLETE,
|
||||
content = "The group signed the event. It took ${session.threshold} of " +
|
||||
"${session.participantCount} members.",
|
||||
actor = session.coordinatorPublicKey
|
||||
)
|
||||
|
||||
logger.i("Signing session ${session.id} complete")
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the signed event into whatever it is: a dialect, an artifact, a
|
||||
* chapter.
|
||||
@@ -974,6 +999,12 @@ object FrostSigningManager {
|
||||
return false
|
||||
}
|
||||
|
||||
// The group signed it without needing this member, and [advance] closes
|
||||
// the session on its next pass without asking them anything. Offering the
|
||||
// decision anyway would be offering two bad answers: a nonce nobody is
|
||||
// waiting for, or a refusal that abandons a signature already made.
|
||||
if (session.signature != null) return false
|
||||
|
||||
return session.signApprovedAt == null
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
@@ -223,10 +224,10 @@ fun FrostSigningScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (session.signApprovedAt == null &&
|
||||
session.stage != FrostSigningStage.COMPLETE &&
|
||||
session.stage != FrostSigningStage.FAILED
|
||||
) {
|
||||
// Asked rather than re-derived: the manager owns when a session
|
||||
// is still waiting on its owner, and a second copy of that rule
|
||||
// here is a second copy to keep in step.
|
||||
if (FrostSigningManager.isAwaitingApproval(session)) {
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
|
||||
@@ -283,30 +283,17 @@ class ChatMessageListViewModel(
|
||||
)
|
||||
} else {
|
||||
|
||||
// A request line is answered when the step it asked for
|
||||
// has since been published by this device -- which is
|
||||
// exactly what approving it does. The transcript already
|
||||
// records that as an authored line, so the answer is here
|
||||
// in the list rather than in the session, and it stays
|
||||
// right for a room that has run more than one ceremony.
|
||||
val answeredRequests = chatRoomDetailMessageListUIState
|
||||
// Which request lines are still asking something of the
|
||||
// reader. Read off the transcript rather than the session
|
||||
// -- the rows are what a line rendered days later has --
|
||||
// and both rules live on ChatMessage, where they can be
|
||||
// stated once and tested.
|
||||
val messages = chatRoomDetailMessageListUIState
|
||||
.chatMessageList
|
||||
.mapNotNull { request ->
|
||||
val published = (
|
||||
ChatMessage.DKG_REQUEST_FULFILMENTS +
|
||||
ChatMessage.FROST_REQUEST_FULFILMENTS
|
||||
)[request.chatMessage.messageType]
|
||||
?: return@mapNotNull null
|
||||
.map { it.chatMessage }
|
||||
|
||||
val done = chatRoomDetailMessageListUIState.chatMessageList.any {
|
||||
it.chatMessage.messageType == published &&
|
||||
it.chatMessage.isUserMessage &&
|
||||
it.chatMessage.createdAt >= request.chatMessage.createdAt
|
||||
}
|
||||
|
||||
request.chatMessage.id.takeIf { done }
|
||||
}
|
||||
.toSet()
|
||||
val answeredRequests = ChatMessage.answeredRequests(messages)
|
||||
val settledRequests = ChatMessage.settledRequests(messages)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
@@ -366,6 +353,7 @@ class ChatMessageListViewModel(
|
||||
RitualNotice(
|
||||
localChatMessage = localChatMessage,
|
||||
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
|
||||
isSettled = localChatMessage.chatMessage.id in settledRequests,
|
||||
onClick = onOpenSharedKey
|
||||
)
|
||||
return@items
|
||||
@@ -394,6 +382,7 @@ class ChatMessageListViewModel(
|
||||
RitualNotice(
|
||||
localChatMessage = localChatMessage,
|
||||
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
|
||||
isSettled = localChatMessage.chatMessage.id in settledRequests,
|
||||
onClick = onOpenSigning
|
||||
)
|
||||
return@items
|
||||
@@ -663,6 +652,7 @@ private fun PrivateMessageNotice(
|
||||
private fun RitualNotice(
|
||||
localChatMessage: LocalChatMessage,
|
||||
isAnswered: Boolean,
|
||||
isSettled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val chatMessage = localChatMessage.chatMessage
|
||||
@@ -704,10 +694,13 @@ private fun RitualNotice(
|
||||
// quiet; these are not.
|
||||
// An answered request is history, not a summons: it keeps its stage's icon so
|
||||
// the step is still recognisable, but drops the colour and the call to action.
|
||||
// So is a settled one -- declined, or signed by a quorum that did not need this
|
||||
// member. Nothing was answered there, so it gets no tick, but offering to
|
||||
// review it would be offering a decision that has already gone by.
|
||||
val isRequest = (
|
||||
chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES ||
|
||||
chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES
|
||||
) && !isAnswered
|
||||
) && !isAnswered && !isSettled
|
||||
|
||||
val tint = when {
|
||||
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user