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>
248 lines
10 KiB
Kotlin
248 lines
10 KiB
Kotlin
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.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 fr.acinq.bitcoin.crypto.frost.TweakCache
|
|
import kotlin.test.Test
|
|
import kotlin.test.assertEquals
|
|
import kotlin.test.assertNotEquals
|
|
import kotlin.test.assertNotNull
|
|
import kotlin.test.assertTrue
|
|
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
|
|
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
|
|
|
/**
|
|
* An artifact the group signed, from proposal to rows, against real FROST.
|
|
*
|
|
* Adding an artifact used to write a row and submit it; the row said the
|
|
* submitter wrote it, because they had. Now the group signs it, and the claim
|
|
* this test exists to hold is that the artifact every device ends up with is
|
|
* the group's: authored by the threshold key, carrying a signature that
|
|
* verifies, with an id every member arrives at independently.
|
|
*
|
|
* None of that is visible when it breaks. A row whose author is the proposer
|
|
* looks exactly like a row whose author is the group -- both are opaque hex --
|
|
* and a group that disagrees about the id has two artifacts that look like one.
|
|
*/
|
|
class SignedArtifactTest {
|
|
private val participants = 3
|
|
private val threshold = 2
|
|
|
|
/** The member who filled in the form. Nothing they own should end up on the row. */
|
|
private val proposer = "9".repeat(64)
|
|
private val dialectId = "b".repeat(64)
|
|
|
|
/** Stands in for a completed ceremony; the test is about what gets signed, not the DKG. */
|
|
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
|
thresholdSecretKey = PrivateKey(
|
|
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
|
|
),
|
|
nParticipants = participants,
|
|
threshold = threshold
|
|
)
|
|
|
|
/**
|
|
* 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
|
|
)
|
|
|
|
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",
|
|
url = "https://example.com/in-detention",
|
|
visibility = "private",
|
|
license = "cc",
|
|
dialectId = dialectId,
|
|
versionLabel = versionLabel,
|
|
createdAt = 1_700_000_000L,
|
|
)
|
|
|
|
/**
|
|
* Exactly what `FrostSigningManager.unsignedEventOf` does, and it must stay
|
|
* exactly that: the proposer's fields re-authored under the group's key.
|
|
*/
|
|
private fun unsignedEventOf(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>) = Event(
|
|
id = EventHasher.hashId(
|
|
pubKey = groupPubKey,
|
|
createdAt = template.createdAt,
|
|
kind = template.kind,
|
|
tags = template.tags,
|
|
content = template.content
|
|
),
|
|
pubKey = groupPubKey,
|
|
createdAt = template.createdAt,
|
|
kind = template.kind,
|
|
tags = template.tags,
|
|
content = template.content,
|
|
sig = ""
|
|
)
|
|
|
|
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 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 ->
|
|
SecretNonce.generate(
|
|
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
|
|
secretShare = keyMaterial.secretShares[signerId],
|
|
publicShare = keyMaterial.publicShares[signerId],
|
|
tweakedThresholdPublicKey = tweakCache.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 { keyMaterial.publicShares[it] },
|
|
nParticipants = participants,
|
|
threshold = threshold,
|
|
tweakCache = tweakCache,
|
|
message = message
|
|
)
|
|
|
|
val partials = signerIds.mapIndexed { position, signerId ->
|
|
signingSession.sign(
|
|
nonces[position].first,
|
|
keyMaterial.secretShares[signerId],
|
|
signerId.toUInt()
|
|
).right!!
|
|
}
|
|
|
|
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
|
|
}
|
|
|
|
/** Everything from the form to the row a device holds afterwards. */
|
|
private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent {
|
|
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
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun `the artifact the group signs is authored by the group, not the proposer`() {
|
|
val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId)
|
|
|
|
assertNotNull(artifact)
|
|
assertEquals(groupPubKey, artifact.publicKey)
|
|
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
|
|
// 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 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(item.eventId, artifact?.id)
|
|
}
|
|
|
|
@Test
|
|
fun `the signature on the row verifies against the row's own id and author`() {
|
|
// The payoff of signing rather than submitting: the row carries proof the
|
|
// group made it, checkable by anybody holding it.
|
|
val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId)
|
|
|
|
assertNotNull(artifact)
|
|
assertTrue(
|
|
Nip01Crypto.verify(
|
|
signature = artifact.signature.hexToByteArray(),
|
|
hash = artifact.id.hexToByteArray(),
|
|
pubKey = artifact.publicKey.hexToByteArray()
|
|
),
|
|
"an artifact row should carry a signature the group's key made over its own id"
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun `what the form asked for is what the group signed`() {
|
|
// The fields travel as tags through a session that knows nothing about
|
|
// artifacts. Anything dropped in there is signed away silently.
|
|
val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId)
|
|
|
|
assertEquals("In Detention", artifact?.name)
|
|
assertEquals("https://example.com/in-detention", artifact?.url)
|
|
assertEquals("private", artifact?.visibility)
|
|
assertEquals("cc", artifact?.license)
|
|
assertEquals(dialectId, artifact?.dialectId)
|
|
}
|
|
|
|
@Test
|
|
fun `the artifact arrives with the first version hanging off it`() {
|
|
// Nothing sends this row: each device derives it from the artifact it
|
|
// just applied. A chapter attaches to a version rather than to an
|
|
// artifact, so an artifact that arrives without one is inert.
|
|
val signed = signedArtifactEvent(versionLabel = "First Edition")
|
|
|
|
val artifact = MantraArtifact.fromArtifactEvent(signed, chatRoomId)
|
|
val version = MantraArtifactVersion.initialVersionOf(signed, chatRoomId)
|
|
|
|
assertNotNull(version)
|
|
assertEquals(artifact?.id, version.artifactId)
|
|
assertEquals("First Edition", version.versionLabel)
|
|
assertEquals(groupPubKey, version.publicKey)
|
|
// Derived, not signed: the group signed the artifact that declares it.
|
|
assertEquals("", version.signature)
|
|
}
|
|
}
|