Merge branch 'mantra' into claude/distracted-franklin-e95ba4

This commit is contained in:
Kgothatso Ngako
2026-09-05 23:24:45 +02:00
51 changed files with 15593 additions and 608 deletions

View File

@@ -0,0 +1,174 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlin.test.Test
import kotlin.test.assertEquals
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
/**
* The row on disk and the payload on the wire have to be the same event.
*
* `MantraDao` writes an entity whose id comes from `MantraX.fromXEventTemplate`,
* and separately builds the rumor it submits with `rumorOf`, which hashes the
* template itself. Both are supposed to produce one id. Nothing checks that they
* do, and nothing would notice if they stopped:
*
* - the submission would carry a `payloadId` naming an event nobody has,
* - `MarmotInnerEvent.payloadEventId` would stop matching the row it carries,
* so `deleteByPayloadEventId` would silently un-queue nothing and superseded
* translations would go out anyway,
* - and every receiver would create a *second* row rather than converging on
* the sender's, because entity ids are content hashes and the two sides would
* be hashing different things.
*
* All of that is silent. The ids are opaque hex either way.
*/
class RumorIdAgreementTest {
private val author = "a".repeat(64)
private val chatRoomId = "room"
private val other = "b".repeat(64)
/** Exactly what `MantraDao.rumorOf` does, and it must stay exactly that. */
private fun rumorIdOf(template: EventTemplate<*>): String = EventHasher.hashId(
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
)
@Test
fun `a dialect's row and its rumor agree`() {
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
val entity = MantraDialect.fromDialectEventTemplate(
dialectEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `an artifact's row and its rumor agree`() {
val template = ArtifactEvent.build(
name = "In Detention",
url = "example.com",
visibility = "private",
license = "cc",
dialectId = other
)
val entity = MantraArtifact.fromArtifactEventTemplate(
artifactEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `an artifact version's row and its rumor agree`() {
val template = ArtifactVersionEvent.build(content = "1.0") {
addUnique(ArtifactIdTag.assemble(other))
}
val entity = MantraArtifactVersion.fromArtifactVersionEventTemplate(
artifactVersionEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `a chapter's and a chunk's rows agree with their rumors`() {
val chapter = ChapterEvent.build(
artifactVersionId = other,
name = "Chapter 1",
originalText = "some text",
index = 0,
wordCount = 2,
characterCount = 9
)
val chunk = ChunkEvent.build(
chapterId = other,
text = "some text",
index = 0,
wordCount = 2,
characterCount = 9
)
assertEquals(
rumorIdOf(chapter),
MantraChapter.fromChapterEventTemplate(chapter, chatRoomId, author)?.id
)
assertEquals(
rumorIdOf(chunk),
MantraChunk.fromChunkEventTemplate(chunk, chatRoomId, author)?.id
)
}
@Test
fun `a translation version's row and its rumor agree`() {
val template = TranslationArtifactVersionEvent.build(
artifactVersionId = other,
dialectId = other,
name = "Sesotho",
visibility = "private",
license = "cc"
)
val entity = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate(
translationArtifactVersionEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `the submission names the id the row was written under`() {
// The end of the chain the rest of this file checks a link of: what a
// receiver reads out of the envelope has to be the id the sender stored.
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
val entity = MantraDialect.fromDialectEventTemplate(template, chatRoomId, author)
val payload = Event(
id = rumorIdOf(template),
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = ""
)
val submission = SubmissionEvent.build(payload = payload)
val readBack = SubmissionEvent(
id = "f".repeat(64),
pubKey = author,
createdAt = submission.createdAt,
tags = submission.tags,
content = submission.content,
sig = ""
)
assertEquals(entity?.id, readBack.payloadId())
assertEquals(entity?.id, readBack.payload()?.id)
assertEquals(DialectEvent.KIND, readBack.payloadKind())
}
}

View File

@@ -0,0 +1,278 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
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 fr.acinq.secp256k1.Hex
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.FrostSigningEvents
/**
* The two rounds a signing session runs, against real FROST.
*
* `FrostSigningManager` spreads these steps across arriving messages, several
* devices and a database, none of which a unit test can stand up. What it can
* do is run the same calls in the same order with the same arguments and check
* that what comes out is a signature nostr will accept — which is the part
* that was written from reading the library rather than from a working example,
* and so the part most likely to be subtly wrong.
*
* A signature that verifies is the whole contract: if these calls are wired up
* incorrectly the aggregate simply fails to verify, silently, on every device.
*/
class FrostSigningRoundTest {
private val participants = 3
private val threshold = 2
/** Stands in for a completed ceremony. A trusted dealer is fine here: the test is about signing. */
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
),
nParticipants = participants,
threshold = threshold
)
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
/** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
/** The 32 bytes actually signed — a nostr event id, exactly as the manager computes it. */
private fun eventId(content: String): String = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = 1_700_000_000L,
kind = 1,
tags = arrayOf(),
content = content
)
/**
* One signer's half of the protocol, in the manager's order: regenerate the
* nonce from stored randomness, then sign once the set is known.
*/
private fun nonceOf(signerId: Int, message: ByteVector, random: String): Pair<SecretNonce, IndividualNonce> =
SecretNonce.generate(
sessionRandom = ByteVector32(random),
secretShare = keyMaterial.secretShares[signerId],
publicShare = keyMaterial.publicShares[signerId],
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
message = message,
extraInput = null
)
private fun sessionFor(signerIds: List<Int>, nonces: List<IndividualNonce>, message: ByteVector): Session {
val aggregated = IndividualNonce.aggregate(nonces).right!!
return Session.create(
aggregatedNonce = aggregated,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
nParticipants = participants,
threshold = threshold,
tweakCache = tweakCache,
message = message
)
}
@Test
fun `a threshold of signers produces a signature nostr accepts`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
// Two of the three sign, which is the point of a 2-of-3 key.
val signerIds = listOf(0, 1)
val nonces = signerIds.map { nonceOf(it, message, "a".repeat(63) + "${it + 1}") }
val session = sessionFor(signerIds, nonces.map { it.second }, message)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
}
val signature = session.aggregateSigs(partials).right!!
assertTrue(
Nip01Crypto.verify(
signature = signature.toByteArray(),
hash = id.hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"the aggregated signature must verify against the group's x-only key"
)
}
@Test
fun `a different pair of signers signs the same event just as well`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
// Whoever happens to be available. The coordinator picks; the signature
// that comes out must not depend on which t it picked.
val signerIds = listOf(1, 2)
val nonces = signerIds.map { nonceOf(it, message, "b".repeat(63) + "${it + 1}") }
val session = sessionFor(signerIds, nonces.map { it.second }, message)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
}
val signature = session.aggregateSigs(partials).right!!
assertTrue(
Nip01Crypto.verify(
signature = signature.toByteArray(),
hash = id.hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
)
)
}
@Test
fun `a signature over one event does not verify against another`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { nonceOf(it, message, "c".repeat(63) + "${it + 1}") }
val session = sessionFor(signerIds, nonces.map { it.second }, message)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
}
val signature = session.aggregateSigs(partials).right!!
assertFalse(
Nip01Crypto.verify(
signature = signature.toByteArray(),
hash = eventId("the group agrees to something else").hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"a signature is over one event id and must not carry to another"
)
}
@Test
fun `regenerating a nonce from the same seed and message gives the same nonce`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
val random = "d".repeat(63) + "1"
// What makes a signing session restart-safe: SecretNonce cannot be stored,
// so the manager keeps its seed and derives again. If that were not
// reproducible a device that restarted mid-session would publish a partial
// signature against a nonce nobody aggregated.
val first = nonceOf(0, message, random).second
val second = nonceOf(0, message, random).second
assertEquals(first.data.toHex(), second.data.toHex())
}
@Test
fun `the same seed under a different message gives a different nonce`() {
val random = "e".repeat(63) + "1"
// The safety property behind reusing the seed at all: one session signs one
// message. Were the nonce independent of the message, a session that could
// be re-pointed at another event would sign twice under one nonce, which
// hands over the secret share.
val first = nonceOf(0, ByteVector(eventId("one thing").hexToByteArray()), random).second
val second = nonceOf(0, ByteVector(eventId("another thing").hexToByteArray()), random).second
assertFalse(first.data.toHex() == second.data.toHex())
}
}
/**
* The pure bits of a signing session's bookkeeping: who is signing, and with
* which key.
*/
class FrostSigningSessionTest {
private fun session(signerId: Int, signerIds: 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 = signerId,
unsignedEventJson = "{}",
eventId = "e".repeat(64),
nonceRandom = "f".repeat(64),
signerIds = signerIds
)
@Test
fun `a member left out of the signer set is not a signer`() {
assertTrue(session(signerId = 1, signerIds = "0,1").isSigner())
assertFalse(session(signerId = 2, signerIds = "0,1").isSigner())
}
@Test
fun `nobody is a signer until the coordinator has chosen`() {
assertFalse(session(signerId = 0, signerIds = null).isSigner())
}
@Test
fun `the signer set keeps the order it was aggregated in`() {
// FROST binds the set into the challenge, so this list is not a set of ids
// but a sequence positionally matched to the aggregated nonce.
assertEquals(listOf(2, 0, 1), session(signerId = 0, signerIds = "2,0,1").signerIdList())
}
@Test
fun `a signer set tag survives the trip through a tag array`() {
val tags = FrostSigningEvents.assembleTags(
sessionId = "session",
dkgSessionId = "ceremony",
signerIds = listOf(2, 0, 1)
)
assertEquals("session", FrostSigningEvents.parseSessionId(tags))
assertEquals("ceremony", FrostSigningEvents.parseKey(tags))
assertEquals(listOf(2, 0, 1), FrostSigningEvents.parseSignerIds(tags))
}
@Test
fun `a ceremony that recorded no public shares reads back null rather than empty`() {
// Ceremonies completed before the column existed. Signing falls back to not
// cross-checking shares, which the FROST API allows, rather than refusing.
val ceremony = DkgSession(
id = "k".repeat(64),
chatRoomId = "room",
coordinatorPublicKey = "c".repeat(64),
userPublicKey = "u".repeat(64),
threshold = 2,
participantCount = 3,
hostPublicKey = "h".repeat(66),
round1Random = "1".repeat(64),
round2AuxRandom = "2".repeat(64)
)
assertEquals(null, ceremony.publicShareList())
assertEquals(
2,
ceremony.copy(
publicShares = listOf(
Hex.encode(ByteArray(33) { 2 }),
Hex.encode(ByteArray(33) { 3 })
).joinToString(",")
).publicShareList()?.size
)
}
}

View File

@@ -0,0 +1,201 @@
package press.mantra.compose.managers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlinx.coroutines.runBlocking
/**
* The decision behind keeping an MLS group alive between messages.
*
* This cache exists because quartz drops a secret tree's skipped-generation keys
* on save, so rebuilding a group between two messages loses any message that
* arrives late -- permanently, and silently. See `docs/mls-skipped-keys.md`.
*
* Every one of these failures is invisible at runtime. Reuse too eagerly and a
* group carries on from a ratchet another writer has already moved, which
* corrupts decryption rather than failing it. Reuse too rarely and the cache
* does nothing at all, and the bug it was written for comes straight back with
* no symptom to notice. So the rule is asserted rather than reasoned about.
*/
class LiveInstanceCacheTest {
/** Stands in for an MlsGroup: mutable, and its persisted form is its content. */
private class Group(var state: String) {
/** How many times this particular instance was handed to a caller. */
var uses: Int = 0
}
private fun cache() = LiveInstanceCache<Group> { it.state }
@Test
fun `reuses the instance while nothing else has written`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
val first = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it.uses++; it }
)
val second = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it.uses++; it }
)
// The same object, not merely an equal one: what has to survive is the
// in-memory skipped-key map, which no amount of rebuilding recovers.
assertSame(first, second)
assertEquals(1, built)
assertEquals(2, second?.uses)
}
@Test
fun `rebuilds when something else wrote the stored state`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
val first = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it }
)
// Sending a message advances the sender ratchet and saves; adding a
// member does too. Carrying on from an instance that has been overtaken
// would diverge the ratchet, which is worse than not caching at all.
stored = "written by someone else"
val second = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("written by someone else") },
save = { stored = it },
block = { it }
)
assertEquals(2, built)
assertEquals(false, first === second)
}
@Test
fun `persists whatever the block left behind`() = runBlocking {
val cache = cache()
var stored: String? = "start"
cache.withInstance(
key = "room",
storedState = stored,
build = { Group("start") },
save = { stored = it },
block = { it.state = "advanced" }
)
assertEquals("advanced", stored)
}
@Test
fun `the state it records is the one it compares against next time`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
repeat(3) {
cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
// Every use moves the instance on, as decrypting a message does.
block = { group -> group.state = "advanced ${group.uses++}" }
)
}
// Recording the pre-block state instead would make every call look like
// somebody else had written, quietly turning the cache off.
assertEquals(1, built)
}
@Test
fun `does not run the block, or cache anything, when there is nothing to build`() = runBlocking {
val cache = cache()
var ran = false
val result = cache.withInstance<Unit>(
key = "room",
storedState = null,
build = { null },
save = { },
block = { ran = true }
)
assertNull(result)
assertEquals(false, ran)
assertEquals(0, cache.size())
}
@Test
fun `an instance whose use threw is not handed to the next caller`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
assertFailsWith<IllegalStateException> {
cache.withInstance<Unit>(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { error("decryption blew up half way") }
)
}
cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it }
)
// Half-advanced and never persisted: the next caller has to start from
// what is actually on disk, not from whatever the failure left in memory.
assertEquals(2, built)
}
@Test
fun `rooms are cached independently`() = runBlocking {
val cache = cache()
var storedA: String? = "a"
var storedB: String? = "b"
val a = cache.withInstance(
key = "roomA",
storedState = storedA,
build = { Group("a") },
save = { storedA = it },
block = { it }
)
val b = cache.withInstance(
key = "roomB",
storedState = storedB,
build = { Group("b") },
save = { storedB = it },
block = { it }
)
assertEquals(2, cache.size())
assertEquals(false, a === b)
}
}

View File

@@ -0,0 +1,126 @@
package press.mantra.compose.nostr.nip30303
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
/**
* What a submission has to survive: the trip through a group.
*
* The envelope is only worth having if the event inside it comes out the other
* side unchanged -- same id, same author, same signature. The moment any of
* those is rewritten in transit, a group can no longer hold work by anyone but
* its own members, which is the whole reason submissions exist.
*/
class SubmissionEventTest {
private val submitter = "a".repeat(64)
private val outsider = "b".repeat(64)
/** A dialect written by somebody who is not in the group. */
private fun outsiderDialect(): Event {
val template = DialectEvent.build(
name = "Sesotho",
country = "Lesotho",
language = "st",
createdAt = 1_700_000_000L,
)
return Event(
id = EventHasher.hashId(
pubKey = outsider,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
),
pubKey = outsider,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = "c".repeat(128),
)
}
/** Send a submission template and read it back the way inbound does. */
private fun roundTrip(payload: Event): SubmissionEvent {
val template = SubmissionEvent.build(payload = payload, createdAt = 1_700_000_100L)
val onTheWire = Event.fromJson(
Event(
id = EventHasher.hashId(
pubKey = submitter,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
),
pubKey = submitter,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = "",
).toJson()
)
return SubmissionEvent(
id = onTheWire.id,
pubKey = onTheWire.pubKey,
createdAt = onTheWire.createdAt,
tags = onTheWire.tags,
content = onTheWire.content,
sig = onTheWire.sig,
)
}
@Test
fun `the payload comes back as the event that went in`() {
val dialect = outsiderDialect()
val payload = roundTrip(dialect).payload()
assertEquals(dialect.id, payload?.id)
assertEquals(dialect.pubKey, payload?.pubKey)
assertEquals(dialect.kind, payload?.kind)
assertEquals(dialect.content, payload?.content)
assertEquals(dialect.sig, payload?.sig)
}
@Test
fun `submitting does not make the submitter the author`() {
val dialect = outsiderDialect()
val submission = roundTrip(dialect)
assertEquals(submitter, submission.pubKey)
assertEquals(outsider, submission.payload()?.pubKey)
assertNotEquals(submission.pubKey, submission.payload()?.pubKey)
}
@Test
fun `the envelope names what it carries without being opened`() {
val dialect = outsiderDialect()
val submission = roundTrip(dialect)
assertEquals(DialectEvent.KIND, submission.payloadKind())
assertEquals(dialect.id, submission.payloadId())
assertEquals(outsider, submission.payloadAuthor())
}
@Test
fun `a payload the group cannot read is null rather than empty`() {
val submission = SubmissionEvent(
id = "d".repeat(64),
pubKey = submitter,
createdAt = 1_700_000_100L,
tags = arrayOf(),
content = "not an event",
sig = "",
)
assertNull(submission.payload())
}
}