feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the next phases follow. Schema only: a session still signs exactly one event, the wire is byte-identical, and every existing test passes on the moved columns. ## What moved, and why it had to A batch of k events is k independent FROST instances sharing a signer set, not one signature over k messages. That is forced rather than chosen: a Schnorr partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under one nonce R give two equations in one unknown and the secret share falls out. So the five columns that enter that equation -- unsignedEventJson, eventId, nonceRandom, aggregatedNonce, signature -- move to a child table keyed (sessionId, itemIndex). What stays on FrostSigningSession is everything outside it: the ceremony, the threshold, the derivation path, the signer set, and the one approval. itemIndex is protocol rather than presentation -- nonces and partial signatures are joined positionally against it -- so getItems() orders by it and nothing re-sorts. Spelled itemIndex rather than index to keep hand-written queries free of backticks. No itemCount column. The count is a COUNT(*), for the same reason signerIds is derived from the ceremony's participant order rather than stored: a denormalised count is one more thing that can disagree with the rows. ## Migration 9 -> 10 Manual, not auto: Room can create the table and drop the columns but cannot copy between them, and the copy is the whole point. A session in flight at upgrade holds its nonce seed and the aggregate it is already signing against, and neither can be regenerated -- losing either makes the next pass derive a different nonce for the same message and publish a second partial signature over it, which is the extraction case. Both are copied verbatim into item 0, so an in-flight session resumes as though nothing happened. Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires cascades -- with foreign keys enforced the rebuild would delete every signer message and every item just written. Whether it does depends on Room disabling foreign keys around migrations, which is not worth depending on when DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed, unconstrained columns; these five qualify, and getRoomDatabase pins BundledSQLiteDriver on every platform. ## Invariants established here for the phases that follow - signerIds and every item's aggregatedNonce are one write-once unit, applied by applyAggregate() -- items first in one transaction, then the session, so "some items aggregated" is unreachable and signerIds != null stays the gate. - Signatures likewise, via applySignatures(); isSigned() counts rows instead of reading a flag. - complete() verifies every signature before applying any event, so a batch is all-or-nothing rather than half-filed. - itemsOver() gives each item its own 32 bytes of seed. Independent seeds mean an off-by-one in index handling produces a session that fails to aggregate rather than one that signs two messages under a single nonce. signedEvent() and isAwaitingApproval() now take the item(s) rather than the session, which propagates to the repository, the view model and the screen. advance() reads items.first() and Phase 2 turns that into a loop. ## Tests - FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert replacing rather than accumulating, signed-item counting, cascade delete. - FrostSigningItemMigrationJvmTest (new): the backfill against a real v9 database, asserting the seed and aggregate values survive -- not merely that a row appeared -- plus the exact column lists Room will check at open time. - 338 jvmTest and 217 testDebugUnitTest pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ 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.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
import press.mantra.compose.extensions.toHex
|
||||
@@ -227,20 +228,30 @@ class FrostSigningSessionTest {
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
signerId = signerId,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "e".repeat(64),
|
||||
nonceRandom = "f".repeat(64),
|
||||
signerIds = signerIds
|
||||
)
|
||||
|
||||
/** The one event such a session signs, signed or not. */
|
||||
private fun items(signature: String? = null) = listOf(
|
||||
FrostSigningItem(
|
||||
sessionId = "s".repeat(64),
|
||||
itemIndex = 0,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "e".repeat(64),
|
||||
nonceRandom = "f".repeat(64),
|
||||
signature = signature
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a session waits on its owner until they answer`() {
|
||||
val open = session(signerId = 2, signerIds = null)
|
||||
|
||||
assertTrue(FrostSigningManager.isAwaitingApproval(open))
|
||||
assertTrue(FrostSigningManager.isAwaitingApproval(open, items()))
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(
|
||||
open.copy(signApprovedAt = Instant.fromEpochSeconds(1))
|
||||
open.copy(signApprovedAt = Instant.fromEpochSeconds(1)),
|
||||
items()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -249,8 +260,12 @@ class FrostSigningSessionTest {
|
||||
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)))
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE), items())
|
||||
)
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED), items())
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -261,9 +276,10 @@ class FrostSigningSessionTest {
|
||||
// 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))
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(signedWithoutThem, items("a".repeat(128)))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -415,7 +431,7 @@ class FrostSigningCompletionTest {
|
||||
* 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(
|
||||
private fun leftOutMemberSession() = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = "room",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
@@ -425,24 +441,30 @@ class FrostSigningCompletionTest {
|
||||
participantCount = 3,
|
||||
signerId = 2,
|
||||
derivationPath = SharedKeyDerivation.formatPath(),
|
||||
signerIds = null,
|
||||
signApprovedAt = null
|
||||
)
|
||||
|
||||
/** The event that session signs: everything completing it needs, and nothing more. */
|
||||
private fun leftOutMemberItem(signature: String? = null) = FrostSigningItem(
|
||||
sessionId = "s".repeat(64),
|
||||
itemIndex = 0,
|
||||
unsignedEventJson = unsignedEvent.toJson(),
|
||||
eventId = unsignedEvent.id,
|
||||
nonceRandom = "f".repeat(64),
|
||||
aggregatedNonce = null,
|
||||
signerIds = null,
|
||||
signature = signature,
|
||||
signApprovedAt = null
|
||||
signature = signature
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a member who never took part can still check what the group signed`() {
|
||||
val session = leftOutMemberSession(signature)
|
||||
val signed = FrostSigningManager.signedEvent(session)!!
|
||||
val item = leftOutMemberItem(signature)
|
||||
val signed = FrostSigningManager.signedEvent(item)!!
|
||||
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signed.sig.hexToByteArray(),
|
||||
hash = session.eventId.hexToByteArray(),
|
||||
hash = item.eventId.hexToByteArray(),
|
||||
pubKey = signed.pubKey.hexToByteArray()
|
||||
),
|
||||
"completing must need only the row: the event, its id and the signature"
|
||||
@@ -454,7 +476,7 @@ class FrostSigningCompletionTest {
|
||||
// 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))!!
|
||||
val signed = FrostSigningManager.signedEvent(leftOutMemberItem(signature))!!
|
||||
|
||||
assertEquals(unsignedEvent.id, signed.id)
|
||||
assertEquals(unsignedEvent.pubKey, signed.pubKey)
|
||||
@@ -466,15 +488,22 @@ class FrostSigningCompletionTest {
|
||||
|
||||
@Test
|
||||
fun `there is no finished event until the signature arrives`() {
|
||||
assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession()))
|
||||
assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberItem()))
|
||||
}
|
||||
|
||||
@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)))
|
||||
assertTrue(
|
||||
FrostSigningManager.isAwaitingApproval(leftOutMemberSession(), listOf(leftOutMemberItem()))
|
||||
)
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(
|
||||
leftOutMemberSession(),
|
||||
listOf(leftOutMemberItem(signature))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -482,7 +511,7 @@ class FrostSigningCompletionTest {
|
||||
// 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(
|
||||
val other = leftOutMemberItem(signature).copy(
|
||||
eventId = EventHasher.hashId(
|
||||
pubKey = groupPubKey,
|
||||
createdAt = 1_700_000_000L,
|
||||
|
||||
@@ -19,7 +19,7 @@ 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.FrostSigningItem
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
@@ -281,21 +281,14 @@ class GroupKeyStateTest {
|
||||
val unsigned = unsignedKeyState(content, tags, createdAt, author)
|
||||
|
||||
return FrostSigningManager.signedEvent(
|
||||
session = sessionOver(unsigned),
|
||||
item = itemOver(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),
|
||||
private fun itemOver(unsignedEvent: Event) = FrostSigningItem(
|
||||
sessionId = "s".repeat(64),
|
||||
itemIndex = 0,
|
||||
unsignedEventJson = unsignedEvent.toJson(),
|
||||
eventId = unsignedEvent.id,
|
||||
nonceRandom = "f".repeat(64)
|
||||
|
||||
@@ -170,9 +170,6 @@ class SharedKeyDerivationTest {
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
signerId = 0,
|
||||
derivationPath = derivationPath,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "e".repeat(64),
|
||||
nonceRandom = "f".repeat(64)
|
||||
derivationPath = derivationPath
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
import press.mantra.compose.extensions.toHex
|
||||
@@ -100,24 +100,17 @@ class SignedArtifactTest {
|
||||
sig = ""
|
||||
)
|
||||
|
||||
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = proposer,
|
||||
userPublicKey = proposer,
|
||||
dkgSessionId = "k".repeat(64),
|
||||
threshold = threshold,
|
||||
participantCount = participants,
|
||||
signerId = 0,
|
||||
derivationPath = SharedKeyDerivation.formatPath(),
|
||||
private fun itemOver(unsignedEvent: Event) = FrostSigningItem(
|
||||
sessionId = "s".repeat(64),
|
||||
itemIndex = 0,
|
||||
unsignedEventJson = unsignedEvent.toJson(),
|
||||
eventId = unsignedEvent.id,
|
||||
nonceRandom = "f".repeat(64)
|
||||
)
|
||||
|
||||
/** A quorum signing the session's event, in the manager's order. */
|
||||
private fun groupSignature(session: FrostSigningSession): String {
|
||||
val message = ByteVector(session.eventId.hexToByteArray())
|
||||
/** A quorum signing the item's event, in the manager's order. */
|
||||
private fun groupSignature(item: FrostSigningItem): String {
|
||||
val message = ByteVector(item.eventId.hexToByteArray())
|
||||
val signerIds = listOf(0, 1)
|
||||
|
||||
val nonces = signerIds.map { signerId ->
|
||||
@@ -154,8 +147,8 @@ class SignedArtifactTest {
|
||||
|
||||
/** Everything from the form to the row a device holds afterwards. */
|
||||
private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent {
|
||||
val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel)))
|
||||
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
|
||||
val item = itemOver(unsignedEventOf(proposalTemplate(versionLabel)))
|
||||
val signed = FrostSigningManager.signedEvent(item, groupSignature(item))
|
||||
|
||||
return ArtifactEvent(
|
||||
signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig
|
||||
@@ -193,15 +186,15 @@ class SignedArtifactTest {
|
||||
// to be the one that was signed rather than anything recomputed from the
|
||||
// proposer. Otherwise members converge on nothing and each holds its own
|
||||
// copy of what is meant to be one artifact.
|
||||
val session = sessionOver(unsignedEventOf(proposalTemplate()))
|
||||
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
|
||||
val item = itemOver(unsignedEventOf(proposalTemplate()))
|
||||
val signed = FrostSigningManager.signedEvent(item, groupSignature(item))
|
||||
|
||||
val artifact = MantraArtifact.fromArtifactEvent(
|
||||
ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig),
|
||||
chatRoomId
|
||||
)
|
||||
|
||||
assertEquals(session.eventId, artifact?.id)
|
||||
assertEquals(item.eventId, artifact?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user