Merge branch 'mantra' into claude/marmot-direct-message-type-7a0473

Twenty-two commits had landed on mantra since this branch left it, several
of them in the same files. Merged this way round so mantra stayed untouched
until the result compiled and its tests passed.

The migration had to be renumbered, and this is the conflict that mattered.
mantra is at database version 7 and already has its own 5.json -- for
MarmotInnerEvent.payloadEventId, nothing to do with direct messages. This
branch had also written a 5.json, for a different schema. Resolved by
restoring mantra's 5.json untouched and moving the direct message columns
to an AutoMigration(7, 8) with a regenerated 8.json. Taking either 5.json
over the other would have left every device validating a migration chain
against a schema it was never built from; keeping version = 5 would have
made a v7 install refuse to open at all.

The regenerated 8.json is two ADD COLUMNs and nothing else, same as before.

fromGroupEventResult was restructured on mantra: the kind switch moved into
applyInnerEvent, and a SubmissionEvent envelope now wraps nip30303 payloads.
Took that structure and re-applied the direct message branch ahead of it
rather than inside it -- a gift wrap is not a nip30303 payload to apply, and
what happens to it depends only on whether this device's key opens it, so it
does not belong in a function about applying submissions.

The isUserMessage fix was re-applied to the eight call sites mantra's
version has, up from the six it had here.

ChatMessageListViewModel and ChatRoomMessagingScreen took mantra's versions
with the composer state, the two renderings and the reply action layered
back on.

docs/README.md keeps both new rows and mantra's closing note about the
skipped-keys document.

108 tests pass, up from 50 here and 83 on mantra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 23:45:08 +02:00
72 changed files with 22707 additions and 748 deletions

View File

@@ -0,0 +1,141 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Who can open a gift wrap, and what becomes of everyone else's.
*
* NIP-59 encrypts a wrap under ECDH(ephemeralPriv, recipientPub), and
* [GiftWrapEvent.create] discards that ephemeral key before it returns. The
* recipient named in the `p` tag is therefore the only party who can ever unseal
* one -- the sender included. Reading a wrap that is not ours is not a decryption
* that might fail, it is one that cannot be attempted, and the code that tried
* anyway threw `IllegalStateException: Invalid Mac` out of Nip44 and took the
* enclosing Room transaction down with it, so the event was rolled back and
* re-fetched on every later sync.
*
* These run real secp256k1 rather than recorded fixtures on purpose: the property
* under test is about the key agreement itself, and a fixture would only prove the
* fixture still parses.
*/
class GiftWrapMessageTest {
private val us = KeyPair()
private val peer = KeyPair()
private val stranger = KeyPair()
/**
* A real wrap, sealed the way DatabaseChatRepository seals one and then mapped
* into the entity the way NostrEvent.toGiftWrapMessageWithReceiverPTag maps it.
*/
private fun wrap(
sender: KeyPair,
recipient: KeyPair,
): GiftWrapMessage {
val signer = NostrSignerSync(sender)
val seal = signer.signNormal<SealedRumorEvent>(
createdAt = SEALED_AT,
kind = SealedRumorEvent.KIND,
tags = emptyArray(),
content = signer.nip44Encrypt(
plaintext = """{"kind":14,"content":"dumela"}""",
toPublicKey = recipient.pubKey.toHexKey(),
),
)
val giftWrap = GiftWrapEvent.create(
event = seal,
recipientPubKey = recipient.pubKey.toHexKey(),
createdAt = WRAPPED_AT,
)
return GiftWrapMessage(
id = giftWrap.id,
publicKey = giftWrap.pubKey,
receiverPublicKey = recipient.pubKey.toHexKey(),
receiverRelayHit = null,
content = giftWrap.content,
signature = giftWrap.sig,
nostrEventId = giftWrap.id,
createdAt = Instant.fromEpochSeconds(giftWrap.createdAt),
)
}
@Test
fun `a wrap addressed to us gives up its seal`() = runTest {
val seal = wrap(sender = peer, recipient = us).decryptGiftWrapSeal(us)
assertNotNull(seal)
// Only the wrap was anonymous. The seal inside carries the real sender, which
// is what lets the impersonation check downstream compare it to the payload.
assertEquals(peer.pubKey.toHexKey(), seal.publicKey)
}
@Test
fun `someone else's mail comes back null rather than throwing`() = runTest {
// The event from the crash report: a wrap between two other people, pulled in
// by a filter that named a peer where it should have named us.
val theirs = wrap(sender = stranger, recipient = peer)
assertNull(theirs.decryptGiftWrapSeal(us))
}
@Test
fun `not even the sender can reopen what they sent`() = runTest {
// What the old branch called "a message we may have sent" and tried to decrypt
// regardless. The key that encrypted it no longer exists anywhere; holding the
// sending identity buys nothing back.
val ours = wrap(sender = us, recipient = peer)
assertNull(ours.decryptGiftWrapSeal(us))
}
@Test
fun `isAddressedTo reads the p tag whatever case it arrived in`() {
val message = wrap(sender = peer, recipient = us)
assertTrue(message.isAddressedTo(us))
assertFalse(message.isAddressedTo(peer))
assertTrue(
message
.copy(receiverPublicKey = message.receiverPublicKey.uppercase())
.isAddressedTo(us),
)
}
@Test
fun `isAddressedTo answers exactly what unsealing would`() = runTest {
// NostrDao skips indexing on isAddressedTo and throws GiftWrapUnsealException on
// a null seal. Should those two ever disagree, one path or the other is wrong:
// either mail we can open is skipped, or the transaction rolls back again.
listOf(
wrap(sender = peer, recipient = us),
wrap(sender = stranger, recipient = peer),
wrap(sender = us, recipient = peer),
).forEach { message ->
assertEquals(
message.isAddressedTo(us),
message.decryptGiftWrapSeal(us) != null,
"isAddressedTo and decryptGiftWrapSeal disagree on ${message.id}",
)
}
}
private companion object {
const val SEALED_AT = 1_700_000_000L
const val WRAPPED_AT = 1_700_000_100L
}
}

View File

@@ -0,0 +1,134 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.time.Instant
/**
* Where a commit's bytes land when the row that records it is written.
*
* `MarmotCommitResult` carries quartz's `CommitResult` payload fields verbatim --
* `commitBytes`, `welcomeBytes`, `groupInfoBytes`, `framedCommitBytes`,
* `preCommitExporterSecret`, the same names and all of them `ByteArray`. A value
* taken from the wrong field of the right object therefore typechecks, and
* `framedCommitBytes = commitResult.preCommitExporterSecret` reached the database
* that way and sat there unnoticed: the column documented to hold a broadcastable
* `MlsMessage(PublicMessage(FramedContent(commit)))` envelope held 32 bytes of the
* group's pre-commit exporter secret instead.
*
* Nothing caught it because nothing read the column. The bytes that reached the
* relay come off the in-memory `CommitResult`, so the wire stayed correct while the
* record of it did not, and the row is written precisely so that the
* acknowledgement path in `DatabaseNostrRepository` can pick work back up later. A
* rebroadcast reading `framedCommitBytes` would have published noise the group
* decrypts, fails to parse, and drops -- silent, which is this subsystem's
* characteristic failure.
*
* So the routing is pinned here. Every payload gets a distinct, self-identifying
* value: a field that ends up in the wrong column names both halves of the mistake
* when it fails, rather than comparing equal by accident.
*/
class MarmotCommitResultMappingTest {
private val commitBytes = "raw-commit".encodeToByteArray()
private val framedCommitBytes = "framed-commit-envelope".encodeToByteArray()
private val welcomeBytes = "welcome".encodeToByteArray()
private val groupInfoBytes = "group-info".encodeToByteArray()
/** Stands in for `MLS-Exporter("marmot", "group-event", 32)` at the pre-commit epoch. */
private val preCommitExporterSecret = ByteArray(32) { 0x5E }
private val commitEventId = "a".repeat(64)
private val chatRoomId = "b".repeat(64)
private val userPublicKey = "c".repeat(64)
private val peerKeyPackageEventId = "d".repeat(64)
private val createdAt = Instant.fromEpochSeconds(1_700_000_000)
private fun commitResult(
framedCommitBytes: ByteArray = this.framedCommitBytes,
preCommitExporterSecret: ByteArray = this.preCommitExporterSecret,
) = CommitResult(
commitBytes = commitBytes,
welcomeBytes = welcomeBytes,
groupInfoBytes = groupInfoBytes,
framedCommitBytes = framedCommitBytes,
preCommitExporterSecret = preCommitExporterSecret,
)
private fun map(commitResult: CommitResult) = MarmotCommitResult.from(
commitEventId = commitEventId,
commitResult = commitResult,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
peerKeyPackageEventId = peerKeyPackageEventId,
isOneMemberInitialGroupCreation = false,
createdAt = createdAt,
)
@Test
fun `every payload field lands in its own column`() {
val row = map(commitResult())
assertContentEquals(commitBytes, row.commitBytes, "commitBytes")
assertContentEquals(welcomeBytes, row.welcomeBytes, "welcomeBytes")
assertContentEquals(groupInfoBytes, row.groupInfoBytes, "groupInfoBytes")
assertContentEquals(framedCommitBytes, row.framedCommitBytes, "framedCommitBytes")
assertContentEquals(
preCommitExporterSecret,
row.preCommitExporterSecret,
"preCommitExporterSecret"
)
}
@Test
fun `the framed commit column never holds the exporter secret`() {
// The regression. Stated as the invariant rather than as an equality check,
// so it keeps holding for a CommitResult this test did not anticipate.
val row = map(commitResult())
assertFalse(
row.framedCommitBytes.contentEquals(row.preCommitExporterSecret),
"the group's exporter secret was stored as the framed commit"
)
}
@Test
fun `a CommitResult that never framed its commit still stores a commit`() {
// quartz defaults framedCommitBytes to commitBytes, and the entity repeats that
// default. Whichever of the two a row ends up with, it must be a commit -- the
// fallback must not quietly become the secret either.
val unframed = CommitResult(
commitBytes = commitBytes,
welcomeBytes = welcomeBytes,
groupInfoBytes = groupInfoBytes,
preCommitExporterSecret = preCommitExporterSecret,
)
val row = map(unframed)
assertContentEquals(commitBytes, row.framedCommitBytes)
assertFalse(
row.framedCommitBytes.contentEquals(row.preCommitExporterSecret),
"the group's exporter secret was stored as the framed commit"
)
}
@Test
fun `the bookkeeping the acknowledgement path reads is carried through`() {
// DatabaseNostrRepository finds this row by the commit event's id and delivers the
// welcome using chatRoomId, userPublicKey and peerKeyPackageEventId. All four are
// supplied by the caller rather than the CommitResult, so they are checked here to
// keep the argument order of `from` honest -- every one of them is a 64-char hex
// string, and swapping two would otherwise typecheck as silently as the bug did.
val row = map(commitResult())
assertEquals(commitEventId, row.id)
assertEquals(chatRoomId, row.chatRoomId)
assertEquals(userPublicKey, row.userPublicKey)
assertEquals(peerKeyPackageEventId, row.peerKeyPackageEventId)
assertEquals(createdAt, row.createdAt)
assertFalse(row.isOneMemberInitialGroupCreation)
}
}

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,245 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.core.Event
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.secp256k1.Hex
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
* What a room's key-state announcement is allowed to convince a member of.
*
* The announcement is made by the coordinator, and the coordinator is untrusted
* by construction -- the same assumption every other part of the ceremony is
* written under. So the interesting cases here are all the ones where a state
* is *wrong*: a member who acts on a state naming a key their room was not made
* from signs with a share that cannot aggregate, or worse, treats a key the
* group does not hold as the key the group holds.
*
* `GroupKeyStateManager` needs a database and so cannot be stood up here. What
* can be is the check it defers to, which is where the whole trust model lives.
*/
class GroupKeyStateTest {
/** Stands in for a ceremony's output. Any valid point will do. */
private val thresholdPublicKey = PrivateKey(
Hex.decode("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
).publicKey().value.toHex()
/** A second group's key, for the states that name the wrong one. */
private val otherKey = PrivateKey(
Hex.decode("2bada550000000000000000000000000000000000000000000000000000000b2")
).publicKey().value.toHex()
private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
private val chatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path)
private fun state(
chatRoomId: String = this.chatRoomId,
thresholdPublicKey: String = this.thresholdPublicKey,
derivationPath: String = SharedKeyDerivation.formatPath(path)
) = GroupKeyState(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
thresholdPublicKey = thresholdPublicKey,
derivationPath = derivationPath,
announcedBy = "c00rd1na70r",
announcedAt = Instant.fromEpochSeconds(1_700_000_000)
)
@Test
fun `a state describing the room it was announced in verifies`() {
assertTrue(state().verifies())
}
@Test
fun `a state naming another group's key does not verify`() {
// The attack this is here for: a coordinator pointing the room at a key
// the group never made, so that everything signed in it is signed by
// whoever holds that key instead.
assertFalse(state(thresholdPublicKey = otherKey).verifies())
}
@Test
fun `a state naming the right key at the wrong path does not verify`() {
// The path is half the derivation, so getting it wrong reaches a
// different room just as surely as getting the key wrong does.
assertFalse(state(derivationPath = "m/9420/0/1").verifies())
}
@Test
fun `a state for one room does not verify against another`() {
assertFalse(state(chatRoomId = otherKey).verifies())
}
@Test
fun `a state carrying an unwalkable path does not verify`() {
// Hardened derivation needs the parent private key, which nobody in a
// threshold group has, so a hardened path was never walked to anything.
assertFalse(state(derivationPath = "m/9420'/0/0").verifies())
assertFalse(state(derivationPath = "9420/0/0").verifies())
assertFalse(state(derivationPath = "").verifies())
}
@Test
fun `the tags a state is announced on read back as they were written`() {
val tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
path = path
)
assertEquals(chatRoomId, GroupKeyStateEvent.parseChatRoomId(tags))
assertEquals("ceremony-1", GroupKeyStateEvent.parseDkgSessionId(tags))
assertEquals(path, GroupKeyStateEvent.parsePath(tags))
}
@Test
fun `a threshold key is only read out of content that is one`() {
assertEquals(thresholdPublicKey, GroupKeyStateEvent.parseThresholdPublicKey(thresholdPublicKey))
// 32 bytes is an x-only key, not the 33-byte compressed point a ceremony
// reports; anything else is not a key at all.
assertNull(GroupKeyStateEvent.parseThresholdPublicKey(chatRoomId))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey(""))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey("not a key"))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey("z".repeat(66)))
}
@Test
fun `a path survives the trip through a tag`() {
val deep = listOf(9420L, 7L, 0L, 1L)
val tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", deep)
assertEquals(deep, GroupKeyStateEvent.parsePath(tags))
}
@Test
fun `a room derived at a path other than the default still verifies at it`() {
// The reason the path is announced rather than assumed: a lookup that
// hardcodes MARMOT_ADMIN_GROUP_PATH cannot find this room at all.
val sibling = listOf(9420L, 0L, 1L)
val siblingRoom = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, sibling)
assertTrue(
state(
chatRoomId = siblingRoom,
derivationPath = SharedKeyDerivation.formatPath(sibling)
).verifies()
)
}
// ---- The announcement as it actually arrives -------------------------
//
// Everything above checks the verdict on a state already assembled. These
// check the assembling: a real Event, with the tags and content an
// announcement is carried on, through the function the inbound path calls.
private fun announcement(
content: String = thresholdPublicKey,
tags: Array<Array<String>> = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path),
pubKey: String = "c00rd1na70r",
createdAt: Long = 1_700_000_000
) = Event(
id = "an-id",
pubKey = pubKey,
createdAt = createdAt,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = content,
sig = ""
)
@Test
fun `an announcement of the room it arrives in is taken`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, announcement())
assertEquals(chatRoomId, state?.chatRoomId)
assertEquals("ceremony-1", state?.dkgSessionId)
assertEquals(thresholdPublicKey, state?.thresholdPublicKey)
assertEquals("m/9420/0/0", state?.derivationPath)
// Attribution and ordering come off the event, not off the clock.
assertEquals("c00rd1na70r", state?.announcedBy)
assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt)
}
@Test
fun `an announcement naming another group's key is dropped`() {
// The one that matters: a coordinator pointing the room at a key the
// group never made. Everything else here is malformed input; this is
// well-formed input that lies.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = otherKey)))
}
@Test
fun `an announcement addressed to another room is dropped`() {
val elsewhere = GroupKeyStateEvent.assembleTags(otherKey, "ceremony-1", path)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = elsewhere)))
}
@Test
fun `an announcement missing any of what it has to say is dropped`() {
val full = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path)
// No ceremony to reach a share through.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
announcement(tags = full.filterNot { it[0] == "frost_key" }.toTypedArray())
)
)
// No path, so nothing to rebuild a TweakCache from.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
announcement(tags = full.filterNot { it[0] == "frost_path" }.toTypedArray())
)
)
// No key.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = "")))
}
@Test
fun `an announcement carrying no d tag is judged on its derivation alone`() {
// The d tag is a convenience for a reader holding the event on its own.
// Dropping it loses nothing that matters, because the room it arrived in
// plus the derivation still settle the question.
val undirected = arrayOf(
arrayOf("frost_key", "ceremony-1"),
arrayOf("frost_path", "m/9420/0/0")
)
assertEquals(
chatRoomId,
GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = undirected))?.chatRoomId
)
}
@Test
fun `a path index wider than a uint32 is not a path`() {
// tweakScalar serialises an index as its low four bytes, so m/4294967296
// would otherwise walk exactly where m/0 does -- one room, two spellings,
// both verifying. Rejected at the parser so formatPath stays a round trip.
assertNull(SharedKeyDerivation.parsePathString("m/4294967296/0/0"))
assertNull(SharedKeyDerivation.parsePathString("m/-1/0/0"))
assertEquals(listOf(4294967295L), SharedKeyDerivation.parsePathString("m/4294967295"))
assertEquals(listOf(0L), SharedKeyDerivation.parsePathString("m/0"))
}
@Test
fun `a state whose path indices are out of range does not verify`() {
// Reachable only by constructing the row directly; the parser above
// refuses to build one. Checked because verifies() is what everything
// else defers to, and it should not be the thing that trusts its input.
assertFalse(state(derivationPath = "m/4294967296/0/0").verifies())
}
}

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,80 @@
package press.mantra.compose.nostr
import press.mantra.compose.database.query.NostrEventFilterQuery
import press.mantra.compose.network.serialization.encodeToJsonString
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Pins the only gift wrap filter that can come back with something we can read.
*
* Every clause here is one that was got wrong in production. Two call sites asked
* for `authors = [our pubkey]`, which cannot match a wrap signed by a throwaway
* key and so returned nothing at all, silently, for as long as it existed. A third
* asked for `p = [the peer]`, which returned other people's mail and crashed the
* save that tried to unseal it. Neither failure was visible from reading the
* filter, so the shape is asserted instead of trusted.
*/
class Nip17FiltersTest {
private val us = "a".repeat(64)
@Test
fun `it asks for wraps addressed to us`() {
assertEquals(mapOf("p" to listOf(us)), Nip17Filters.inbox(us).tags)
}
@Test
fun `it constrains no authors`() {
// GiftWrapEvent.create signs with a key it generates and drops, so the author
// of a wrap is a value nobody can predict -- least of all the sender's own
// pubkey. Any authors clause here silently matches zero events on every relay.
assertNull(Nip17Filters.inbox(us).authors)
}
@Test
fun `it carries no since cursor`() {
// A wrap is stamped up to two days earlier than it was sent, so a high-water
// mark taken from the newest wrap we hold skips mail that arrives behind it.
// Anything reintroducing `since` has to back-date by at least two days first.
assertNull(Nip17Filters.inbox(us).since)
assertNull(Nip17Filters.inbox(us).until)
}
@Test
fun `it asks for gift wraps and nothing else`() {
assertEquals(listOf(1059), Nip17Filters.inbox(us).kinds?.toList())
}
@Test
fun `two callers asking for the same inbox make one request`() {
// computeId hashes the encoded filter, so the chat room list and the chat
// message screen collapse into a single negentropy request only while both
// encode identically. Building the filter once is what holds that true; the
// wire shape is asserted so an added default cannot quietly split them.
assertEquals(Nip17Filters.inbox(us), Nip17Filters.inbox(us))
assertEquals(
"""{"kinds":[1059],"tags":{"p":["$us"]},"limit":50}""",
Nip17Filters.inbox(us).encodeToJsonString(),
)
}
@Test
fun `the local set it builds is the same set the relay is asked for`() {
// Negentropy reconciles our local set against the relay's: this filter goes out
// in NEG-OPEN, and the local side is built by running the same filter through
// NostrEventFilterQuery. A clause that survives one trip and not the other
// reports differences that are not real -- events re-downloaded forever, or
// pushed at a relay that excluded them on purpose. What matters here is that
// the local query reads the p tag and, like the wire filter, bounds no author:
// an authors clause would show up as `pubKey IN (?)`.
val query = NostrEventFilterQuery.build(Nip17Filters.inbox(us))
assertEquals(
"SELECT * FROM NostrEvent WHERE kind IN (?) AND (tags LIKE ? ESCAPE '\\') " +
"ORDER BY createdAt DESC, id DESC",
query.sql,
)
}
}

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())
}
}