Merge branch 'mantra' into claude/marmot-group-reindex-events-96a0d0
# Conflicts: # composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt # composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
|
||||
/**
|
||||
* The first version of an artifact is derived, not delivered.
|
||||
*
|
||||
* The group signs an artifact and nothing else, so the version it starts life
|
||||
* with is not an event anybody sent: every device builds the row for itself out
|
||||
* of the artifact it already holds. That only works while every device builds
|
||||
* the *same* row, and nothing about the ids would show it if they stopped —
|
||||
* they are content hashes, opaque hex either way. What would show is a group
|
||||
* that quietly disagrees about which version a chapter hangs off, with the
|
||||
* artifact looking identical on every screen.
|
||||
*/
|
||||
class InitialArtifactVersionTest {
|
||||
private val groupKey = "a".repeat(64)
|
||||
private val dialectId = "b".repeat(64)
|
||||
private val chatRoomId = "room"
|
||||
|
||||
private fun signedArtifact(
|
||||
name: String = "In Detention",
|
||||
versionLabel: String = "1.0",
|
||||
createdAt: Long = 1_700_000_000,
|
||||
): ArtifactEvent {
|
||||
val template = ArtifactEvent.build(
|
||||
name = name,
|
||||
url = "example.com",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = dialectId,
|
||||
versionLabel = versionLabel,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
// Hashed rather than made up, so two fixtures that differ are two
|
||||
// different artifacts here for the same reason they would be in the app.
|
||||
return ArtifactEvent(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content
|
||||
),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "d".repeat(128)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the derived version is a function of the artifact and nothing else`() {
|
||||
// Every input has to come off the artifact. Reading the clock here would
|
||||
// still agree with itself twice in a row -- and disagree between two
|
||||
// devices that applied the same artifact minutes apart, which is the
|
||||
// case nobody can reproduce on demand. So the timestamp is checked
|
||||
// against the artifact's rather than against a second derivation.
|
||||
val artifact = signedArtifact(createdAt = 1_700_000_000)
|
||||
|
||||
val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
|
||||
assertNotNull(version)
|
||||
assertEquals(1_700_000_000, version.createdAt.epochSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two devices derive the same first version from the same artifact`() {
|
||||
val artifact = signedArtifact()
|
||||
|
||||
val mine = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
val theirs = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
|
||||
assertNotNull(mine)
|
||||
assertEquals(mine.id, theirs?.id)
|
||||
assertEquals(mine.createdAt, theirs?.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact signed at a different moment derives a different version`() {
|
||||
// The artifact's own timestamp is bound into the derived id, so two
|
||||
// proposals identical but for when they were made stay two artifacts
|
||||
// with two first versions rather than colliding on one row.
|
||||
val first = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_000), chatRoomId)
|
||||
val second = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_001), chatRoomId)
|
||||
|
||||
assertNotNull(first)
|
||||
assertNotNull(second)
|
||||
assertNotEquals(first.id, second.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the derived version hangs off the artifact and carries what it declared`() {
|
||||
val artifact = signedArtifact(versionLabel = "First Edition")
|
||||
|
||||
val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
|
||||
assertEquals(artifact.id, version?.artifactId)
|
||||
assertEquals("First Edition", version?.versionLabel)
|
||||
// Authored by whoever authored the artifact -- the group, once signed --
|
||||
// and unsigned, because nobody signed this.
|
||||
assertEquals(groupKey, version?.publicKey)
|
||||
assertEquals("", version?.signature)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the label is bound into the id rather than hung beside it`() {
|
||||
// Two artifacts alike but for the label must not derive one version
|
||||
// between them: the id has to come from the whole event, or a group
|
||||
// renaming a version would leave the row it replaces in place.
|
||||
val first = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "1.0"), chatRoomId)
|
||||
val second = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "2.0"), chatRoomId)
|
||||
|
||||
assertNotNull(first)
|
||||
assertNotNull(second)
|
||||
assertNotEquals(first.id, second.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact that declares no version derives none`() {
|
||||
// Artifacts written before the artifact carried its first version.
|
||||
val declared = signedArtifact()
|
||||
val silent = ArtifactEvent(
|
||||
id = declared.id,
|
||||
pubKey = declared.pubKey,
|
||||
createdAt = declared.createdAt,
|
||||
tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
|
||||
.toTypedArray(),
|
||||
content = declared.content,
|
||||
sig = declared.sig
|
||||
)
|
||||
|
||||
assertNull(MantraArtifactVersion.initialVersionOf(silent, chatRoomId))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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,
|
||||
versionLabel = "1.0"
|
||||
)
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* When a request line in a transcript stops asking for something.
|
||||
*
|
||||
* The transcript renders a request with a tint and a "Review" affordance, and
|
||||
* that is a promise: tapping it leads to a decision still there to be made.
|
||||
* Keeping the promise means knowing when the decision has gone, and the rows are
|
||||
* all there is to know it from -- a line rendered days later has no session to
|
||||
* ask, and the room may have signed several things since.
|
||||
*
|
||||
* Two ways for a request to be over, and they are not the same. Answering it
|
||||
* leaves a line of the reader's own and earns the tick. A session ending
|
||||
* underneath it leaves nothing of theirs at all: a member who declined published
|
||||
* nothing, and a quorum that signed without them wanted nothing. Both must drop
|
||||
* the summons; neither may claim the member signed.
|
||||
*/
|
||||
class TranscriptRequestStateTest {
|
||||
private val user = "u".repeat(64)
|
||||
private val other = "o".repeat(64)
|
||||
|
||||
private var lastId = 0L
|
||||
|
||||
/** One transcript row, with only the four fields either rule reads. */
|
||||
private fun line(
|
||||
type: String,
|
||||
at: Long,
|
||||
sender: String = user
|
||||
) = ChatMessage(
|
||||
id = ++lastId,
|
||||
senderPublicKey = sender,
|
||||
isUserMessage = sender == user,
|
||||
giftWrapPayloadId = null,
|
||||
marmotGroupEventId = null,
|
||||
marmotInnerEventId = null,
|
||||
chatRoomId = "room",
|
||||
content = "",
|
||||
messageType = type,
|
||||
createdAt = Instant.fromEpochSeconds(at)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a signing request nobody has acted on is still asking`() {
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
|
||||
val transcript = listOf(line(ChatMessage.TYPE_FROST_STARTED, at = 9, sender = other), request)
|
||||
|
||||
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
|
||||
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `publishing the nonce answers the request that asked for it`() {
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
|
||||
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11))
|
||||
|
||||
assertEquals(setOf(request.id), ChatMessage.answeredRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `somebody else's nonce answers nothing`() {
|
||||
// The fulfilment has to be this device's own: a transcript is full of other
|
||||
// members taking the step this reader has yet to take.
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
|
||||
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11, sender = other))
|
||||
|
||||
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `declining settles the request without claiming it was signed`() {
|
||||
// Declining publishes nothing, so there is no fulfilment to find. The
|
||||
// failure the refusal writes is the only trace, and it has to be enough --
|
||||
// otherwise the line goes on offering a decision already made.
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
|
||||
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_FAILED, at = 11))
|
||||
|
||||
assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript))
|
||||
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group that signs without this member settles their request`() {
|
||||
// A t-of-n key does not need everybody. Nothing of this member's is in the
|
||||
// signature and nothing of theirs was ever published, so answered stays
|
||||
// empty -- but there is no longer anything for them to decide.
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
|
||||
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_COMPLETE, at = 12, sender = other))
|
||||
|
||||
assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript))
|
||||
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an earlier session's ending does not close a later request`() {
|
||||
// Rooms sign more than once, and the previous session's last line sits
|
||||
// above this one's first.
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 20)
|
||||
val transcript = listOf(line(ChatMessage.TYPE_FROST_COMPLETE, at = 9, sender = other), request)
|
||||
|
||||
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ceremony step is settled by nothing`() {
|
||||
// Signing is the one thing a member can refuse, so it is the only place a
|
||||
// request can be over without them having answered it. A ceremony step is
|
||||
// either taken or still waited on, and reading either ending as the end of
|
||||
// one would drop a summons the ritual is still stalled on.
|
||||
val request = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 10)
|
||||
val transcript = listOf(
|
||||
request,
|
||||
line(ChatMessage.TYPE_FROST_FAILED, at = 11),
|
||||
line(ChatMessage.TYPE_DKG_FAILED, at = 12, sender = other)
|
||||
)
|
||||
|
||||
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each ceremony step is answered only by its own`() {
|
||||
val hostKey = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY, at = 10)
|
||||
val roundOne = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 12)
|
||||
val transcript = listOf(hostKey, line(ChatMessage.TYPE_DKG_HOST_KEY, at = 11), roundOne)
|
||||
|
||||
assertEquals(setOf(hostKey.id), ChatMessage.answeredRequests(transcript))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -17,8 +18,10 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
|
||||
@@ -217,6 +220,39 @@ class FrostSigningSessionTest {
|
||||
signerIds = signerIds
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a session waits on its owner until they answer`() {
|
||||
val open = session(signerId = 2, signerIds = null)
|
||||
|
||||
assertTrue(FrostSigningManager.isAwaitingApproval(open))
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(
|
||||
open.copy(signApprovedAt = Instant.fromEpochSeconds(1))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session that has settled asks its owner nothing`() {
|
||||
val open = session(signerId = 2, signerIds = null)
|
||||
|
||||
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE)))
|
||||
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signature the group already made asks its owner nothing either`() {
|
||||
// A t-of-n key does not need everybody, so a quorum can finish while one
|
||||
// member's phone is still in a pocket. The session stays at its opening
|
||||
// stage on their device until it next advances, and offering them the
|
||||
// decision in that window offers two bad answers: a nonce nobody is
|
||||
// waiting for, or a refusal that abandons a signature that exists.
|
||||
val signedWithoutThem = session(signerId = 2, signerIds = "0,1")
|
||||
.copy(signature = "a".repeat(128))
|
||||
|
||||
assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a member left out of the signer set is not a signer`() {
|
||||
assertTrue(session(signerId = 1, signerIds = "0,1").isSigner())
|
||||
@@ -276,3 +312,175 @@ class FrostSigningSessionTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a device needs on its row to finish a session it never took part in.
|
||||
*
|
||||
* `FrostSigningManager.advance` completes on an arrived signature ahead of the
|
||||
* approval gate, and that hoist rests on one claim: closing a session needs
|
||||
* nothing secret and nothing the member would have had to publish. Were it
|
||||
* false -- were the aggregated nonce, the signer set or a share needed to check
|
||||
* the result -- the gate would have to stay where it was, and a member the
|
||||
* quorum did not need would be stuck being asked to sign something already
|
||||
* signed.
|
||||
*
|
||||
* So the claim is spelled out here against a real 2-of-3 signature, from the
|
||||
* row of the member who was left out of it.
|
||||
*/
|
||||
class FrostSigningCompletionTest {
|
||||
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("2decade0000000000000000000000000000000000000000000000000000000b2")
|
||||
),
|
||||
nParticipants = 3,
|
||||
threshold = 2
|
||||
)
|
||||
|
||||
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
|
||||
|
||||
/** The group's nostr identity, exactly as `unsignedEventOf` derives it. */
|
||||
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
|
||||
|
||||
/** The event the group is asked to sign, built the way the manager builds it. */
|
||||
private val unsignedEvent = Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupPubKey,
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = 1,
|
||||
tags = arrayOf(),
|
||||
content = "a dialect the group agreed on"
|
||||
),
|
||||
pubKey = groupPubKey,
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = 1,
|
||||
tags = arrayOf(),
|
||||
content = "a dialect the group agreed on",
|
||||
sig = ""
|
||||
)
|
||||
|
||||
/** A real signature from members 0 and 1. Member 2 is not in it and never was. */
|
||||
private val signature: String = run {
|
||||
val message = ByteVector(unsignedEvent.id.hexToByteArray())
|
||||
val signerIds = listOf(0, 1)
|
||||
|
||||
val nonces = signerIds.map { signerId ->
|
||||
SecretNonce.generate(
|
||||
sessionRandom = ByteVector32("c".repeat(63) + "${signerId + 1}"),
|
||||
secretShare = keyMaterial.secretShares[signerId],
|
||||
publicShare = keyMaterial.publicShares[signerId],
|
||||
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
|
||||
message = message,
|
||||
extraInput = null
|
||||
)
|
||||
}
|
||||
|
||||
val session = Session.create(
|
||||
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
|
||||
signerIds = signerIds.map { it.toUInt() },
|
||||
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
|
||||
nParticipants = 3,
|
||||
threshold = 2,
|
||||
tweakCache = tweakCache,
|
||||
message = message
|
||||
)
|
||||
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
|
||||
session.aggregateSigs(partials).right!!.toHex()
|
||||
}
|
||||
|
||||
/**
|
||||
* Member 2's row, as it stands when the signature reaches them: they never
|
||||
* approved, so nothing of theirs was ever published, and the coordinator
|
||||
* never named them. Every column the completion path reads is here; the ones
|
||||
* it must not need are deliberately left null.
|
||||
*/
|
||||
private fun leftOutMemberSession(signature: String? = null) = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = "room",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
userPublicKey = "u".repeat(64),
|
||||
dkgSessionId = "k".repeat(64),
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
signerId = 2,
|
||||
unsignedEventJson = unsignedEvent.toJson(),
|
||||
eventId = unsignedEvent.id,
|
||||
nonceRandom = "f".repeat(64),
|
||||
aggregatedNonce = null,
|
||||
signerIds = null,
|
||||
signature = signature,
|
||||
signApprovedAt = null
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a member who never took part can still check what the group signed`() {
|
||||
val session = leftOutMemberSession(signature)
|
||||
val signed = FrostSigningManager.signedEvent(session)!!
|
||||
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signed.sig.hexToByteArray(),
|
||||
hash = session.eventId.hexToByteArray(),
|
||||
pubKey = signed.pubKey.hexToByteArray()
|
||||
),
|
||||
"completing must need only the row: the event, its id and the signature"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the finished event is the one that was proposed, with a signature on it`() {
|
||||
// Not rebuilt and not rehashed: the id a session is pinned to is the id
|
||||
// the signature is over, so anything that changed here would produce an
|
||||
// event whose signature verifies against nothing.
|
||||
val signed = FrostSigningManager.signedEvent(leftOutMemberSession(signature))!!
|
||||
|
||||
assertEquals(unsignedEvent.id, signed.id)
|
||||
assertEquals(unsignedEvent.pubKey, signed.pubKey)
|
||||
assertEquals(unsignedEvent.createdAt, signed.createdAt)
|
||||
assertEquals(unsignedEvent.kind, signed.kind)
|
||||
assertEquals(unsignedEvent.content, signed.content)
|
||||
assertEquals(signature, signed.sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `there is no finished event until the signature arrives`() {
|
||||
assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the arrived signature is what stops the session asking`() {
|
||||
// The pair that matters to the screen and the transcript: the same row,
|
||||
// before and after the group finished without this member.
|
||||
assertTrue(FrostSigningManager.isAwaitingApproval(leftOutMemberSession()))
|
||||
assertFalse(FrostSigningManager.isAwaitingApproval(leftOutMemberSession(signature)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signature over a different event is refused`() {
|
||||
// What the check is for. A coordinator passing off something else must not
|
||||
// get it applied and announced as the group's, and the row is all there is
|
||||
// to catch it with.
|
||||
val other = leftOutMemberSession(signature).copy(
|
||||
eventId = EventHasher.hashId(
|
||||
pubKey = groupPubKey,
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = 1,
|
||||
tags = arrayOf(),
|
||||
content = "something else entirely"
|
||||
)
|
||||
)
|
||||
|
||||
val signed = FrostSigningManager.signedEvent(other)!!
|
||||
|
||||
assertFalse(
|
||||
Nip01Crypto.verify(
|
||||
signature = signed.sig.hexToByteArray(),
|
||||
hash = other.eventId.hexToByteArray(),
|
||||
pubKey = signed.pubKey.hexToByteArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GIFT_WRAP_SUB_ID
|
||||
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GROUP_SUB_ID_PREFIX
|
||||
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.giftWrapFilter
|
||||
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupChunks
|
||||
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupFilter
|
||||
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupIdsFrom
|
||||
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupSubId
|
||||
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.subscriptionIndex
|
||||
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.subscriptionKey
|
||||
import press.mantra.compose.network.sockets.NostrIncomingMessage
|
||||
import press.mantra.compose.network.sockets.endsLiveSubscription
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
|
||||
class LiveSubscriptionPlanTest {
|
||||
|
||||
private val me = "a".repeat(64)
|
||||
|
||||
// --- filters ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The one that will look like an oversight to whoever reads it next.
|
||||
*
|
||||
* NIP-59 randomizes a gift wrap's `created_at` into the past, and our own outbound path
|
||||
* stamps them with `TimeUtils.randomWithTwoDays()` — so a wrap published this second can
|
||||
* carry a timestamp two days old. A `since` anywhere near the present silently drops a
|
||||
* large share of genuinely new messages, and the symptom is "some DMs just never arrive":
|
||||
* no error, no log, nothing to grep for.
|
||||
*/
|
||||
@Test
|
||||
fun `the gift wrap filter carries no since`() {
|
||||
assertNull(giftWrapFilter(me).since)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the gift wrap filter asks for wraps addressed to us`() {
|
||||
val filter = giftWrapFilter(me)
|
||||
|
||||
assertEquals(listOf(GiftWrapEvent.KIND), filter.kinds)
|
||||
assertEquals(mapOf("p" to listOf(me)), filter.tags)
|
||||
assertEquals(100, filter.limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* `limit` is not a cap on the subscription — NIP-01 scopes it to the stored events a relay
|
||||
* sends before EOSE, explicitly not to the stream after it. It bounds what a reconnect
|
||||
* costs; it does not bound what arrives live.
|
||||
*/
|
||||
@Test
|
||||
fun `the group filter asks for the given groups, and also carries no since`() {
|
||||
val filter = groupFilter(listOf("group-a", "group-b"))
|
||||
|
||||
assertEquals(listOf(GroupEvent.KIND), filter.kinds)
|
||||
assertEquals(mapOf("h" to listOf("group-a", "group-b")), filter.tags)
|
||||
assertEquals(500, filter.limit)
|
||||
assertNull(filter.since)
|
||||
}
|
||||
|
||||
// --- which groups are watched -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `only MLS rooms are watched`() {
|
||||
val rooms = listOf(
|
||||
room(id = "mls", mlsGroupState = "state"),
|
||||
// A NIP-17 room has no group state; its messages arrive as gift wraps instead, so
|
||||
// h-tag subscribing to it would ask for events that do not exist.
|
||||
room(id = "nip17", mlsGroupState = null),
|
||||
)
|
||||
|
||||
assertEquals(listOf("mls"), groupIdsFrom(rooms))
|
||||
}
|
||||
|
||||
/**
|
||||
* A room we left keeps its history locally and must stop pulling new messages. Getting
|
||||
* this wrong is not merely wasteful: it means still receiving from a group we are no
|
||||
* longer a member of.
|
||||
*/
|
||||
@Test
|
||||
fun `rooms we have left or deleted are not watched`() {
|
||||
val rooms = listOf(
|
||||
room(id = "here", mlsGroupState = "state"),
|
||||
room(id = "left", mlsGroupState = "state", leftGroupAt = Instant.fromEpochSeconds(10)),
|
||||
room(id = "gone", mlsGroupState = "state", deletedAt = Instant.fromEpochSeconds(10)),
|
||||
)
|
||||
|
||||
assertEquals(listOf("here"), groupIdsFrom(rooms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorted, so the same membership in a different row order is the same value and the
|
||||
* `distinctUntilChanged` upstream of the reconcile does not re-send a filter that has not
|
||||
* actually changed.
|
||||
*/
|
||||
@Test
|
||||
fun `group ids come out sorted`() {
|
||||
val rooms = listOf("c", "a", "b").map { room(id = it, mlsGroupState = "state") }
|
||||
|
||||
assertEquals(listOf("a", "b", "c"), groupIdsFrom(rooms))
|
||||
}
|
||||
|
||||
// --- chunking and subscription ids -------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `groups are chunked to bound the size of one filter's tag array`() {
|
||||
val chunks = groupChunks((1..250).map { "group-$it" })
|
||||
|
||||
assertEquals(3, chunks.size)
|
||||
assertEquals(listOf(100, 100, 50), chunks.map { it.size })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no groups means no subscriptions`() {
|
||||
assertTrue(groupChunks(emptyList()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subscription key round-trips back to its chunk index`() {
|
||||
val key = subscriptionKey(relayUrl = "wss://relay.example.com", subId = groupSubId(3))
|
||||
|
||||
assertEquals("wss://relay.example.com|${GROUP_SUB_ID_PREFIX}3", key)
|
||||
assertEquals(3, subscriptionIndex(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* `subscriptionIndex` decides which subscriptions get closed when membership shrinks —
|
||||
* anything at or past the new chunk count goes. Unparseable therefore has to read as "past
|
||||
* the end", so a key nobody recognises is closed rather than kept open forever.
|
||||
*/
|
||||
@Test
|
||||
fun `an unrecognisable key sorts past the end so it gets closed`() {
|
||||
assertEquals(Int.MAX_VALUE, subscriptionIndex("wss://relay.example.com|$GIFT_WRAP_SUB_ID"))
|
||||
assertEquals(Int.MAX_VALUE, subscriptionIndex("nonsense"))
|
||||
}
|
||||
|
||||
// --- what ends a live subscription -------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `EOSE does not end a live subscription, CLOSED does`() {
|
||||
assertFalse(NostrIncomingMessage.EoseMessage(subscriptionId = "s").endsLiveSubscription())
|
||||
assertFalse(NostrIncomingMessage.EventMessage(subscriptionId = "s").endsLiveSubscription())
|
||||
assertFalse(NostrIncomingMessage.NoticeMessage(message = "hi").endsLiveSubscription())
|
||||
|
||||
assertTrue(
|
||||
NostrIncomingMessage.ClosedMessage(subscriptionId = "s", message = "rate-limited")
|
||||
.endsLiveSubscription()
|
||||
)
|
||||
}
|
||||
|
||||
private fun room(
|
||||
id: String,
|
||||
mlsGroupState: String?,
|
||||
leftGroupAt: Instant? = null,
|
||||
deletedAt: Instant? = null,
|
||||
) = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = id,
|
||||
userPublicKey = me,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = mlsGroupState,
|
||||
leftGroupAt = leftGroupAt,
|
||||
deletedAt = deletedAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.NegentropySynchronizeRequest
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GIFT_WRAP_SUB_ID
|
||||
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupSubId
|
||||
import press.mantra.compose.network.relays.LiveSubscriptionTransport
|
||||
import press.mantra.compose.network.sockets.NostrIncomingMessage
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The requirement this whole change exists to meet: the group subscription has to follow group
|
||||
* membership, without anyone remembering to call a subscribe function when a group is joined.
|
||||
*
|
||||
* The failure this guards against is entirely silent. A group whose id never makes it into the
|
||||
* `#h` filter is not an error anywhere — it is a conversation that simply never delivers, on a
|
||||
* screen that looks exactly like an empty one.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LiveSubscriptionReconcileTest {
|
||||
|
||||
private val keyPair = KeyPair()
|
||||
private val me = keyPair.pubKey.toHexString()
|
||||
|
||||
@Test
|
||||
fun `opens a group subscription for the groups we are in`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val rooms = MutableStateFlow(listOf(room("group-a"), room("group-b")))
|
||||
val running = start(transport, rooms)
|
||||
|
||||
settle()
|
||||
|
||||
val opened = transport.opened.single { it.reqCommand.subId == groupSubId(0) }
|
||||
assertEquals(
|
||||
mapOf("h" to listOf("group-a", "group-b")),
|
||||
opened.reqCommand.filters.single().tags,
|
||||
)
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
/**
|
||||
* The loop that closes: a Welcome arrives on the gift wrap subscription, a ChatRoom row is
|
||||
* written, the room list re-emits, and the filter widens — with no chat screen involved.
|
||||
*
|
||||
* It has to widen *in place*. Closing and re-opening would drop the live tail of every
|
||||
* group already in that chunk for as long as the round trip takes, so joining one group
|
||||
* would briefly stop delivery on all the others.
|
||||
*/
|
||||
@Test
|
||||
fun `joining a group widens the filter without reopening the subscription`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val rooms = MutableStateFlow(listOf(room("group-a")))
|
||||
val running = start(transport, rooms)
|
||||
settle()
|
||||
|
||||
val opensBefore = transport.opened.count { it.reqCommand.subId == groupSubId(0) }
|
||||
|
||||
rooms.value = listOf(room("group-a"), room("group-new"))
|
||||
settle()
|
||||
|
||||
val updated = transport.updated.last { it.reqCommand.subId == groupSubId(0) }
|
||||
assertEquals(
|
||||
mapOf("h" to listOf("group-a", "group-new")),
|
||||
updated.reqCommand.filters.single().tags,
|
||||
)
|
||||
assertEquals(
|
||||
opensBefore,
|
||||
transport.opened.count { it.reqCommand.subId == groupSubId(0) },
|
||||
"widening must not re-open the subscription",
|
||||
)
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaving a group drops it from the filter`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val rooms = MutableStateFlow(listOf(room("group-a"), room("group-b")))
|
||||
val running = start(transport, rooms)
|
||||
settle()
|
||||
|
||||
rooms.value = listOf(room("group-a"), room("group-b", leftGroupAt = Instant.fromEpochSeconds(1)))
|
||||
settle()
|
||||
|
||||
val updated = transport.updated.last { it.reqCommand.subId == groupSubId(0) }
|
||||
assertEquals(mapOf("h" to listOf("group-a")), updated.reqCommand.filters.single().tags)
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaving every group closes the subscription`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val rooms = MutableStateFlow(listOf(room("group-a")))
|
||||
val running = start(transport, rooms)
|
||||
settle()
|
||||
|
||||
rooms.value = emptyList()
|
||||
settle()
|
||||
|
||||
assertTrue(transport.closed.any { it.subId == groupSubId(0) })
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership churn inside the debounce window must collapse. Joining a group writes the
|
||||
* room, its participants and placeholder profiles in quick succession, each of which
|
||||
* re-emits the list — so without this a single join re-sends the filter several times.
|
||||
*/
|
||||
@Test
|
||||
fun `rapid membership changes collapse into one update`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val rooms = MutableStateFlow(listOf(room("group-a")))
|
||||
val running = start(transport, rooms)
|
||||
settle()
|
||||
|
||||
val updatesBefore = transport.updated.size
|
||||
|
||||
rooms.value = listOf(room("group-a"), room("group-b"))
|
||||
advanceTimeBy(100)
|
||||
rooms.value = listOf(room("group-a"), room("group-b"), room("group-c"))
|
||||
advanceTimeBy(100)
|
||||
rooms.value = listOf(room("group-a"), room("group-b"), room("group-c"), room("group-d"))
|
||||
settle()
|
||||
|
||||
assertEquals(1, transport.updated.size - updatesBefore, "expected one update, not three")
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a NIP-17 room never becomes a group subscription`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val rooms = MutableStateFlow(listOf(room("dm-room", mlsGroupState = null)))
|
||||
val running = start(transport, rooms)
|
||||
settle()
|
||||
|
||||
assertTrue(transport.opened.none { it.reqCommand.subId.startsWith("live-groups-") })
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
// --- what arrives on a subscription -------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `events are stored against the relay they arrived from`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val nostr = RecordingNostrRepository()
|
||||
val running = start(transport, MutableStateFlow(emptyList()), nostr)
|
||||
settle()
|
||||
|
||||
val subscription = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }
|
||||
subscription.messages.emit(
|
||||
NostrIncomingMessage.EventMessage(subscriptionId = GIFT_WRAP_SUB_ID, nostrEvent = event("ev-1"))
|
||||
)
|
||||
settle()
|
||||
|
||||
val saved = nostr.saved.single()
|
||||
assertEquals("ev-1", saved.first)
|
||||
assertEquals(subscription.relayUrl, saved.second)
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
/** EOSE is a marker, not an end: what follows it is the whole point. */
|
||||
@Test
|
||||
fun `an event after EOSE is still stored`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val nostr = RecordingNostrRepository()
|
||||
val running = start(transport, MutableStateFlow(emptyList()), nostr)
|
||||
settle()
|
||||
|
||||
val subscription = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }
|
||||
subscription.messages.emit(NostrIncomingMessage.EoseMessage(subscriptionId = GIFT_WRAP_SUB_ID))
|
||||
subscription.messages.emit(
|
||||
NostrIncomingMessage.EventMessage(subscriptionId = GIFT_WRAP_SUB_ID, nostrEvent = event("after-eose"))
|
||||
)
|
||||
settle()
|
||||
|
||||
assertEquals(listOf("after-eose"), nostr.saved.map { it.first })
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a relay that closes the subscription gets it re-opened`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val running = start(transport, MutableStateFlow(emptyList()))
|
||||
settle()
|
||||
|
||||
val first = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }
|
||||
first.messages.emit(
|
||||
NostrIncomingMessage.ClosedMessage(subscriptionId = GIFT_WRAP_SUB_ID, message = "shutting down")
|
||||
)
|
||||
|
||||
settle(1.seconds)
|
||||
assertEquals(
|
||||
1,
|
||||
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
|
||||
"should still be inside the back-off, not hammering the relay",
|
||||
)
|
||||
|
||||
settle(30.seconds)
|
||||
assertEquals(
|
||||
2,
|
||||
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
|
||||
"a CLOSED subscription should have been re-opened once the back-off elapsed",
|
||||
)
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-pressure is the one refusal that must NOT be answered promptly — opening another
|
||||
* subscription is exactly what the relay just asked us to stop doing.
|
||||
*/
|
||||
@Test
|
||||
fun `a rate-limited close waits far longer before re-opening`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val running = start(transport, MutableStateFlow(emptyList()))
|
||||
settle()
|
||||
|
||||
transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }.messages.emit(
|
||||
NostrIncomingMessage.ClosedMessage(
|
||||
subscriptionId = GIFT_WRAP_SUB_ID,
|
||||
message = "rate-limited: too many concurrent REQs",
|
||||
)
|
||||
)
|
||||
settle(30.seconds)
|
||||
assertEquals(
|
||||
1,
|
||||
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
|
||||
"must not re-open within the window a plain failure would have",
|
||||
)
|
||||
|
||||
// ...but it is a back-off, not a give-up.
|
||||
settle(5.minutes)
|
||||
assertEquals(
|
||||
2,
|
||||
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
|
||||
"the subscription should come back once the relay has had its breathing room",
|
||||
)
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
// --- lifecycle -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `backgrounding closes the subscriptions and foregrounding rebuilds them`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val foreground = MutableStateFlow(true)
|
||||
val running = start(transport, MutableStateFlow(emptyList()), isForeground = foreground)
|
||||
settle()
|
||||
|
||||
val openedWhileForeground = transport.opened.size
|
||||
assertTrue(openedWhileForeground > 0)
|
||||
|
||||
foreground.value = false
|
||||
settle()
|
||||
assertTrue(transport.closed.any { it.subId == GIFT_WRAP_SUB_ID }, "should have said goodbye")
|
||||
|
||||
foreground.value = true
|
||||
settle()
|
||||
assertTrue(transport.opened.size > openedWhileForeground, "should have re-subscribed")
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
/**
|
||||
* A live subscription covers the window we are connected for; it cannot answer for the gap
|
||||
* we were away. That is negentropy's job, and returning to the foreground is precisely
|
||||
* when it needs asking.
|
||||
*/
|
||||
@Test
|
||||
fun `foregrounding reconnects and queues a catch-up reconciliation`() = runTest {
|
||||
val transport = FakeTransport()
|
||||
val nostr = RecordingNostrRepository()
|
||||
val running = start(transport, MutableStateFlow(listOf(room("group-a"))), nostr)
|
||||
settle()
|
||||
|
||||
assertEquals(1, transport.reconnects, "the socket must not be trusted after a gap")
|
||||
|
||||
val purposes = nostr.queued.map { it.purpose }.toSet()
|
||||
assertTrue("chat" in purposes, "gift wraps should be reconciled")
|
||||
assertTrue("mlsMessages" in purposes, "group events should be reconciled")
|
||||
|
||||
running.cancelAndJoin()
|
||||
}
|
||||
|
||||
// --- harness -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Advances virtual time by exactly [duration] and runs what that makes due.
|
||||
*
|
||||
* Deliberately not `advanceUntilIdle()`: that runs until nothing is scheduled at all,
|
||||
* which means it fast-forwards through *any* pending delay — including the five-minute
|
||||
* back-off this suite needs to assert has NOT elapsed. A test that cannot tell "waited"
|
||||
* from "did not wait" cannot test a back-off at all.
|
||||
*/
|
||||
private fun kotlinx.coroutines.test.TestScope.settle(duration: Duration = 1.seconds) {
|
||||
advanceTimeBy(duration)
|
||||
runCurrent()
|
||||
}
|
||||
|
||||
private fun kotlinx.coroutines.test.TestScope.start(
|
||||
transport: FakeTransport,
|
||||
rooms: MutableStateFlow<List<LocalChatRoom>>,
|
||||
nostr: RecordingNostrRepository = RecordingNostrRepository(),
|
||||
isForeground: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
) = launch {
|
||||
LiveSubscriptionManager(
|
||||
relaysSocketManager = transport,
|
||||
nostrRepository = nostr,
|
||||
chatRepository = FakeChatRepository(rooms),
|
||||
isForeground = isForeground,
|
||||
).observe(keyPair)
|
||||
}
|
||||
|
||||
private fun room(
|
||||
id: String,
|
||||
mlsGroupState: String? = "state",
|
||||
leftGroupAt: Instant? = null,
|
||||
) = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = id,
|
||||
userPublicKey = me,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = mlsGroupState,
|
||||
leftGroupAt = leftGroupAt,
|
||||
)
|
||||
)
|
||||
|
||||
private fun event(id: String) = NostrEvent(
|
||||
id = id,
|
||||
pubKey = me,
|
||||
createdAt = Instant.fromEpochSeconds(1_000),
|
||||
kind = 1059,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "",
|
||||
)
|
||||
}
|
||||
|
||||
private class OpenedSubscription(val reqCommand: ReqCmd, val relayUrl: String) {
|
||||
val messages = MutableSharedFlow<NostrIncomingMessage>(extraBufferCapacity = 32)
|
||||
}
|
||||
|
||||
private class FakeTransport : LiveSubscriptionTransport {
|
||||
val opened = mutableListOf<OpenedSubscription>()
|
||||
val updated = mutableListOf<OpenedSubscription>()
|
||||
val closed = mutableListOf<ClosedSubscription>()
|
||||
var reconnects = 0
|
||||
|
||||
data class ClosedSubscription(val subId: String, val relayUrl: String)
|
||||
|
||||
override suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow<NostrIncomingMessage> =
|
||||
OpenedSubscription(reqCommand, relayUrl).also { opened += it }.messages.asSharedFlow()
|
||||
|
||||
override suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) {
|
||||
updated += OpenedSubscription(reqCommand, relayUrl)
|
||||
}
|
||||
|
||||
override suspend fun closeLiveSubscription(subId: String, relayUrl: String) {
|
||||
closed += ClosedSubscription(subId, relayUrl)
|
||||
}
|
||||
|
||||
override suspend fun reconnectAll() {
|
||||
reconnects++
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeChatRepository(
|
||||
private val rooms: StateFlow<List<LocalChatRoom>>,
|
||||
) : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
|
||||
|
||||
override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow<List<LocalChatRoom>> = rooms
|
||||
|
||||
override suspend fun getChatRoomListByPublicKey(publicKey: String): List<LocalChatRoom> = rooms.value
|
||||
}
|
||||
|
||||
private class RecordingNostrRepository : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
|
||||
/** (event id, relay it arrived from) */
|
||||
val saved = mutableListOf<Pair<String, String>>()
|
||||
val queued = mutableListOf<NegentropySynchronizeRequest>()
|
||||
|
||||
override suspend fun saveNostrEvent(
|
||||
nostrEvent: NostrEvent,
|
||||
relayURL: String,
|
||||
synchronizationRelayURLs: List<String>,
|
||||
level: Int,
|
||||
activeKeyPair: KeyPair,
|
||||
) {
|
||||
saved += nostrEvent.id to relayURL
|
||||
}
|
||||
|
||||
override suspend fun queueNegentropySynchronizeRequest(
|
||||
negentropySynchronizeRequests: List<NegentropySynchronizeRequest>,
|
||||
) {
|
||||
queued += negentropySynchronizeRequests
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The one hole deliberately left in MIP-03's author check, and its edges.
|
||||
*
|
||||
* MIP-03 requires an inner application event's pubkey to equal the MLS sender's credential
|
||||
* identity, which is what stops a member minting events attributed to somebody else. A
|
||||
* gift wrap cannot satisfy it -- its pubkey is a throwaway key that names nobody -- so
|
||||
* kind:1059 is exempt, and the authorship claim moves to the signed seal inside.
|
||||
*
|
||||
* The exemption is the most dangerous line in this feature. Widened to another kind, or
|
||||
* stripped of its kind guard, it hands every member of every group the ability to publish
|
||||
* events as anybody, and nothing else in the pipeline would notice. These tests exist so
|
||||
* that widening it fails here rather than in a group.
|
||||
*/
|
||||
class MarmotMip03CarveOutTest {
|
||||
private val sender = "a".repeat(64)
|
||||
private val somebodyElse = "b".repeat(64)
|
||||
private val throwaway = "c".repeat(64)
|
||||
|
||||
@Test
|
||||
fun `an ordinary message from its own author is accepted`() {
|
||||
assertNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, sender))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary message claiming somebody else is rejected`() {
|
||||
val reason = MarmotInboundManager.mip03Rejection(ChatEvent.KIND, somebodyElse, sender)
|
||||
|
||||
assertNotNull(reason)
|
||||
assertTrue(reason.startsWith("MIP-03"), "expected the MIP-03 rejection, got: $reason")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a gift wrap is accepted despite naming nobody`() {
|
||||
// The carve-out. The wrap's pubkey is a throwaway key and matches no member, which
|
||||
// is the point of it; what authenticates the sender is the MLS frame, and what
|
||||
// authenticates the author is the seal inside. See MarmotDirectMessage.classify.
|
||||
assertNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, sender))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the carve-out does not extend to any other kind`() {
|
||||
// Every kind carried as an application payload today, plus the seal and rumor
|
||||
// kinds a gift wrap contains -- none of which should ever arrive unwrapped, and
|
||||
// all of which would be a way to launder an author if the guard were widened.
|
||||
val kinds = listOf(
|
||||
ChatEvent.KIND,
|
||||
ChatMessageEvent.KIND,
|
||||
SealedRumorEvent.KIND,
|
||||
GiftWrapEvent.KIND - 1,
|
||||
GiftWrapEvent.KIND + 1,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
|
||||
kinds.forEach { kind ->
|
||||
assertNotNull(
|
||||
MarmotInboundManager.mip03Rejection(kind, somebodyElse, sender),
|
||||
"kind $kind was allowed to claim an author it does not own",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sender with no credential identity is rejected, wrap or not`() {
|
||||
// Required rather than merely compared: with no identity there is nothing to
|
||||
// attribute a line to, and for a gift wrap there is nothing to check the seal
|
||||
// against either. Both kinds must fail, not just the one that does a comparison.
|
||||
assertNotNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, null))
|
||||
assertNotNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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.FrostSigningSession
|
||||
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
|
||||
private val chatRoomId = "room"
|
||||
|
||||
/** 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
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
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 sessionOver(unsignedEvent: Event) = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = proposer,
|
||||
userPublicKey = proposer,
|
||||
dkgSessionId = "k".repeat(64),
|
||||
threshold = threshold,
|
||||
participantCount = participants,
|
||||
signerId = 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())
|
||||
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 session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel)))
|
||||
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
|
||||
|
||||
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 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 session = sessionOver(unsignedEventOf(proposalTemplate()))
|
||||
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
|
||||
|
||||
val artifact = MantraArtifact.fromArtifactEvent(
|
||||
ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig),
|
||||
chatRoomId
|
||||
)
|
||||
|
||||
assertEquals(session.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package press.mantra.compose.network.relays
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Pins which CLOSED reasons mean "ease off".
|
||||
*
|
||||
* The classification decides what happens next and is expensive to get wrong in both
|
||||
* directions: a refusal read as a transient failure is answered by opening another
|
||||
* subscription, which is how one "too many concurrent REQs" becomes a flood of them, while an
|
||||
* unsupported filter read as back-pressure leaves a subscription shut for five minutes over a
|
||||
* problem no amount of waiting fixes.
|
||||
*/
|
||||
class RelayBackPressureTest {
|
||||
|
||||
@Test
|
||||
fun `recognises the NIP-01 machine-readable prefix`() {
|
||||
assertTrue(isRelayBackPressure("rate-limited: slow down there chief"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognises the free-text forms relays actually send`() {
|
||||
listOf(
|
||||
"ERROR: too many concurrent REQs",
|
||||
"rate limit exceeded",
|
||||
"Please slow down",
|
||||
"TOO MANY SUBSCRIPTIONS",
|
||||
"maximum concurrent subscriptions reached",
|
||||
).forEach { reason ->
|
||||
assertTrue(isRelayBackPressure(reason), "expected back-pressure: $reason")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not read a refusal we cannot wait out as back-pressure`() {
|
||||
listOf(
|
||||
"auth-required: we can't serve DMs to unauthenticated users",
|
||||
"unsupported: filter contains unknown tag",
|
||||
"invalid: filter is empty",
|
||||
"error: negentropy disabled",
|
||||
"blocked: you are not allowed to write here",
|
||||
).forEach { reason ->
|
||||
assertFalse(isRelayBackPressure(reason), "did not expect back-pressure: $reason")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A CLOSED with no reason at all is common. Treating it as back-pressure would mean the
|
||||
* least informative refusal produced the longest possible outage.
|
||||
*/
|
||||
@Test
|
||||
fun `a missing or empty reason is not back-pressure`() {
|
||||
assertFalse(isRelayBackPressure(null))
|
||||
assertFalse(isRelayBackPressure(""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package press.mantra.compose.network.relays
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import press.mantra.compose.network.dto.RelayDTO
|
||||
import press.mantra.compose.network.sockets.NostrIncomingMessage
|
||||
import press.mantra.compose.network.sockets.NostrSocketClient
|
||||
import press.mantra.compose.network.sockets.NostrSocketClientFactory
|
||||
import press.mantra.compose.network.sockets.SocketConnectionClosedCallback
|
||||
import press.mantra.compose.network.sockets.SocketConnectionOpenedCallback
|
||||
import press.mantra.compose.network.sockets.SocketConnectionReopenedCallback
|
||||
import press.mantra.compose.repository.CachingImportRepository
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The pool's half of surviving a dropped socket.
|
||||
*
|
||||
* A subscription that outlives its socket only works if the pool remembers what that socket was
|
||||
* carrying and hands it back on reconnect. None of that is observable from the outside — no
|
||||
* return value changes, nothing throws — so a regression here looks like "messages stopped
|
||||
* arriving after a tunnel", hours later, on someone else's phone.
|
||||
*
|
||||
* The other half is that a relay answers a repeated REQ on an existing subscription id by
|
||||
* replacing that subscription's filter, which is what makes replay a send rather than a
|
||||
* close-and-reopen.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RelayPoolSubscriptionTest {
|
||||
|
||||
private val relayUrl = "wss://relay.example.com"
|
||||
private val otherRelayUrl = "wss://other.example.com"
|
||||
|
||||
private fun req(subId: String, kind: Int = 1) =
|
||||
ReqCmd(subId = subId, filters = listOf(Filter(kinds = listOf(kind))))
|
||||
|
||||
@Test
|
||||
fun `a query is retained, and replayed when its socket comes back`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
pool.query(req("sub-1"), relayUrl)
|
||||
|
||||
val socket = factory.only()
|
||||
assertEquals(1, socket.sent.size, "the REQ should have gone out once")
|
||||
assertTrue(socket.autoReconnect, "a relay carrying a subscription is worth reconnecting")
|
||||
|
||||
socket.sent.clear()
|
||||
factory.reopen(relayUrl)
|
||||
|
||||
assertEquals(1, socket.sent.size, "the REQ should have been replayed")
|
||||
assertTrue(socket.sent.single().contains("sub-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a closed query is forgotten, and the socket stops reconnecting for it`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
pool.query(req("sub-1"), relayUrl)
|
||||
pool.closeQuery(CloseCmd(subId = "sub-1"), relayUrl)
|
||||
|
||||
val socket = factory.only()
|
||||
assertFalse(socket.autoReconnect, "nothing is open on this relay any more")
|
||||
|
||||
socket.sent.clear()
|
||||
factory.reopen(relayUrl)
|
||||
|
||||
assertTrue(socket.sent.isEmpty(), "a closed subscription must not come back on reconnect")
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing one of two must not drop the other — the bug this guards is a shared socket
|
||||
* quietly losing its remaining subscription because a sibling finished first.
|
||||
*/
|
||||
@Test
|
||||
fun `closing one subscription leaves the rest of that relay's alone`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
pool.query(req("sub-1"), relayUrl)
|
||||
pool.query(req("sub-2", kind = 7), relayUrl)
|
||||
pool.closeQuery(CloseCmd(subId = "sub-1"), relayUrl)
|
||||
|
||||
val socket = factory.only()
|
||||
assertTrue(socket.autoReconnect, "sub-2 is still open")
|
||||
|
||||
socket.sent.clear()
|
||||
factory.reopen(relayUrl)
|
||||
|
||||
assertEquals(1, socket.sent.size)
|
||||
assertTrue(socket.sent.single().contains("sub-2"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Negentropy is stateful: NEG-OPEN carries a fingerprint of the local set and every round
|
||||
* depends on the last. Replaying one mid-exchange would reconcile against a conversation
|
||||
* the relay is no longer having, so an interrupted exchange is abandoned and re-queued
|
||||
* instead.
|
||||
*/
|
||||
@Test
|
||||
fun `a negentropy exchange is never replayed`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
pool.negentropySync(
|
||||
NegOpenCmd(subId = "neg-1", filter = Filter(kinds = listOf(1)), initialMessage = "6100"),
|
||||
relayUrl,
|
||||
)
|
||||
|
||||
val socket = factory.only()
|
||||
assertEquals(1, socket.sent.size, "NEG-OPEN still goes out")
|
||||
|
||||
socket.sent.clear()
|
||||
factory.reopen(relayUrl)
|
||||
|
||||
assertTrue(socket.sent.isEmpty(), "a half-finished reconciliation must not be resumed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a live subscription is retained like any other`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
pool.openLiveSubscription(req("live-giftwrap"), relayUrl)
|
||||
|
||||
val socket = factory.only()
|
||||
assertTrue(socket.autoReconnect)
|
||||
|
||||
socket.sent.clear()
|
||||
factory.reopen(relayUrl)
|
||||
|
||||
assertTrue(socket.sent.single().contains("live-giftwrap"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Widening a live subscription has to replace what gets replayed, not just what is on the
|
||||
* wire now. Retaining the old filter would mean a reconnect quietly restored a
|
||||
* subscription the caller had already moved on from — a group you just joined going
|
||||
* silent the first time you walked through a tunnel.
|
||||
*/
|
||||
@Test
|
||||
fun `updating a live subscription replaces what a reconnect replays`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
pool.openLiveSubscription(req("live-groups-0", kind = 445), relayUrl)
|
||||
pool.updateLiveSubscription(
|
||||
ReqCmd(
|
||||
subId = "live-groups-0",
|
||||
filters = listOf(Filter(kinds = listOf(445), tags = mapOf("h" to listOf("group-b")))),
|
||||
),
|
||||
relayUrl,
|
||||
)
|
||||
|
||||
val socket = factory.only()
|
||||
socket.sent.clear()
|
||||
factory.reopen(relayUrl)
|
||||
|
||||
val replayed = socket.sent.single()
|
||||
assertTrue(replayed.contains("group-b"), "expected the current filter, got: $replayed")
|
||||
}
|
||||
|
||||
/**
|
||||
* An update that cannot be sent must still be recorded. Throwing instead would leave the
|
||||
* previous filter retained, which is the one outcome worse than not sending at all.
|
||||
*/
|
||||
@Test
|
||||
fun `an update whose send fails is still retained for the reconnect`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
pool.openLiveSubscription(req("live-groups-0", kind = 445), relayUrl)
|
||||
|
||||
val socket = factory.only()
|
||||
socket.failSends = true
|
||||
pool.updateLiveSubscription(
|
||||
ReqCmd(
|
||||
subId = "live-groups-0",
|
||||
filters = listOf(Filter(kinds = listOf(445), tags = mapOf("h" to listOf("group-b")))),
|
||||
),
|
||||
relayUrl,
|
||||
)
|
||||
|
||||
socket.failSends = false
|
||||
socket.sent.clear()
|
||||
factory.reopen(relayUrl)
|
||||
|
||||
assertTrue(socket.sent.single().contains("group-b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dropping a relay forgets what it was carrying`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
pool.query(req("sub-1"), relayUrl)
|
||||
pool.changeRelays(listOf(RelayDTO(url = otherRelayUrl, read = true, write = true)))
|
||||
|
||||
val dropped = factory.forUrl(relayUrl)
|
||||
dropped.sent.clear()
|
||||
factory.reopen(relayUrl)
|
||||
|
||||
assertTrue(dropped.sent.isEmpty(), "a relay we removed should not be re-subscribed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `closing the pool forgets everything`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
pool.query(req("sub-1"), relayUrl)
|
||||
pool.closePool()
|
||||
|
||||
val socket = factory.only()
|
||||
socket.sent.clear()
|
||||
factory.reopen(relayUrl)
|
||||
|
||||
assertTrue(socket.sent.isEmpty())
|
||||
}
|
||||
|
||||
/** Retention is per relay: one relay's reconnect must not re-send another's REQ. */
|
||||
@Test
|
||||
fun `replay is scoped to the relay that reconnected`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
pool.query(req("sub-here"), relayUrl)
|
||||
pool.query(req("sub-there"), otherRelayUrl)
|
||||
|
||||
factory.forUrl(relayUrl).sent.clear()
|
||||
factory.forUrl(otherRelayUrl).sent.clear()
|
||||
|
||||
factory.reopen(relayUrl)
|
||||
|
||||
assertTrue(factory.forUrl(relayUrl).sent.single().contains("sub-here"))
|
||||
assertTrue(factory.forUrl(otherRelayUrl).sent.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* The semantic the whole change rests on. For a one-shot request EOSE is the end; for a
|
||||
* live one it is only the boundary between the history a relay had stored and the tail it
|
||||
* will now stream. Ending there is precisely what made every chat sync a poll.
|
||||
*/
|
||||
@Test
|
||||
fun `a live subscription keeps delivering after EOSE`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
val received = mutableListOf<NostrIncomingMessage>()
|
||||
val flow = pool.openLiveSubscription(req("live-giftwrap"), relayUrl)
|
||||
val collecting = launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
flow.collect { received += it }
|
||||
}
|
||||
|
||||
val socket = factory.only()
|
||||
socket.deliver(NostrIncomingMessage.EoseMessage(subscriptionId = "live-giftwrap"))
|
||||
socket.deliver(NostrIncomingMessage.EventMessage(subscriptionId = "live-giftwrap"))
|
||||
|
||||
assertEquals(2, received.size, "the event after EOSE should still have arrived")
|
||||
assertTrue(collecting.isActive, "a live subscription ends when its collector stops, not at EOSE")
|
||||
|
||||
collecting.cancel()
|
||||
}
|
||||
|
||||
/** The contrast, so the two rules cannot silently converge. */
|
||||
@Test
|
||||
fun `a one-shot query still ends at EOSE`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
val received = mutableListOf<NostrIncomingMessage>()
|
||||
val flow = pool.query(req("sub-1"), relayUrl)
|
||||
val collecting = launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
flow.collect { received += it }
|
||||
}
|
||||
|
||||
val socket = factory.only()
|
||||
socket.deliver(NostrIncomingMessage.EoseMessage(subscriptionId = "sub-1"))
|
||||
socket.deliver(NostrIncomingMessage.EventMessage(subscriptionId = "sub-1"))
|
||||
|
||||
assertEquals(1, received.size, "nothing should arrive after the EOSE that ended it")
|
||||
assertFalse(collecting.isActive, "the collector should have completed")
|
||||
}
|
||||
|
||||
/**
|
||||
* A NOTICE carries no subscription id, so the socket hands it to every collector. It is
|
||||
* admitted deliberately — it is the only signal some relays give for "negentropy disabled"
|
||||
* — but it must stay advisory, never terminal, or one relay's complaint would tear down
|
||||
* every unrelated subscription on that socket.
|
||||
*/
|
||||
@Test
|
||||
fun `a NOTICE reaches a live subscription without ending it`() = runTest {
|
||||
val factory = FakeSocketClientFactory()
|
||||
val pool = pool(factory)
|
||||
|
||||
val received = mutableListOf<NostrIncomingMessage>()
|
||||
val flow = pool.openLiveSubscription(req("live-giftwrap"), relayUrl)
|
||||
val collecting = launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
flow.collect { received += it }
|
||||
}
|
||||
|
||||
factory.only().deliver(NostrIncomingMessage.NoticeMessage(message = "restricted: slow"))
|
||||
|
||||
assertEquals(1, received.size)
|
||||
assertTrue(collecting.isActive)
|
||||
|
||||
collecting.cancel()
|
||||
}
|
||||
|
||||
private fun kotlinx.coroutines.test.TestScope.pool(factory: FakeSocketClientFactory) =
|
||||
RelayPool(
|
||||
nostrSocketClientFactory = factory,
|
||||
cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY,
|
||||
// Unconfined so the pool's launched work — status updates, and the replay a
|
||||
// reconnect triggers — has run by the time the call that started it returns.
|
||||
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeSocketClientFactory : NostrSocketClientFactory {
|
||||
private val clients = mutableMapOf<String, FakeNostrSocketClient>()
|
||||
private val reopenCallbacks = mutableMapOf<String, SocketConnectionReopenedCallback?>()
|
||||
|
||||
override fun create(
|
||||
wssUrl: String,
|
||||
incomingCompressionEnabled: Boolean,
|
||||
onSocketConnectionOpened: SocketConnectionOpenedCallback?,
|
||||
onSocketConnectionClosed: SocketConnectionClosedCallback?,
|
||||
onSocketConnectionReopened: SocketConnectionReopenedCallback?,
|
||||
): NostrSocketClient {
|
||||
reopenCallbacks[wssUrl] = onSocketConnectionReopened
|
||||
|
||||
return FakeNostrSocketClient(wssUrl).also { clients[wssUrl] = it }
|
||||
}
|
||||
|
||||
fun only(): FakeNostrSocketClient = clients.values.single()
|
||||
|
||||
fun forUrl(url: String): FakeNostrSocketClient = clients.getValue(url)
|
||||
|
||||
/** Stands in for a socket that dropped and re-established its session. */
|
||||
fun reopen(url: String) {
|
||||
reopenCallbacks.getValue(url)?.invoke(url)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeNostrSocketClient(override val socketUrl: String) : NostrSocketClient {
|
||||
val sent = mutableListOf<String>()
|
||||
var failSends = false
|
||||
|
||||
private val _incomingMessages = MutableSharedFlow<NostrIncomingMessage>(extraBufferCapacity = 64)
|
||||
override val incomingMessages: SharedFlow<NostrIncomingMessage> = _incomingMessages.asSharedFlow()
|
||||
|
||||
override var autoReconnect: Boolean = false
|
||||
|
||||
override suspend fun close() = Unit
|
||||
|
||||
override suspend fun ensureSocketConnectionOrThrow() = Unit
|
||||
|
||||
override suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean) {
|
||||
if (failSends) throw IllegalStateException("socket is down")
|
||||
sent += text
|
||||
}
|
||||
|
||||
override suspend fun sendAUTH(signedEvent: JsonObject) = Unit
|
||||
|
||||
override suspend fun sendCLOSE(subscriptionId: String) = Unit
|
||||
|
||||
override suspend fun sendCOUNT(data: JsonObject): String = "unused"
|
||||
|
||||
override suspend fun sendEVENT(signedEvent: JsonObject) = Unit
|
||||
|
||||
override suspend fun sendREQ(subscriptionId: String, data: JsonObject) = Unit
|
||||
|
||||
/** Stands in for a message arriving on the wire. */
|
||||
suspend fun deliver(message: NostrIncomingMessage) {
|
||||
_incomingMessages.emit(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package press.mantra.compose.network.sockets
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class ReconnectBackoffTest {
|
||||
|
||||
private val initial = 1.seconds
|
||||
private val max = 60.seconds
|
||||
|
||||
private fun delay(attempt: Int, jitter: Double = 0.0) =
|
||||
reconnectDelay(
|
||||
attempt = attempt,
|
||||
initialDelay = initial,
|
||||
maxDelay = max,
|
||||
jitterFraction = 0.25,
|
||||
jitter = jitter,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `doubles once per previous failure`() {
|
||||
assertEquals(1.seconds, delay(attempt = 1))
|
||||
assertEquals(2.seconds, delay(attempt = 2))
|
||||
assertEquals(4.seconds, delay(attempt = 3))
|
||||
assertEquals(8.seconds, delay(attempt = 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stops growing at the cap`() {
|
||||
assertEquals(max, delay(attempt = 7))
|
||||
assertEquals(max, delay(attempt = 50))
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason [MAX_RECONNECT_EXPONENT] exists. `2.0.pow(4000)` is `Infinity`, and
|
||||
* `Duration * Double` throws on it — so without the clamp a socket that had been failing
|
||||
* for long enough turned its own reconnect loop into a crash loop, at roughly the moment
|
||||
* the network was least likely to recover unaided.
|
||||
*/
|
||||
@Test
|
||||
fun `survives an attempt count large enough to overflow the doubling`() {
|
||||
assertEquals(max, delay(attempt = 4_000))
|
||||
assertEquals(max, delay(attempt = Int.MAX_VALUE))
|
||||
}
|
||||
|
||||
/** Defensive: a caller that starts counting at zero should still get a usable delay. */
|
||||
@Test
|
||||
fun `treats a non-positive attempt as the first one`() {
|
||||
assertEquals(initial, delay(attempt = 0))
|
||||
assertEquals(initial, delay(attempt = -3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adds at most the jitter fraction on top, never subtracts`() {
|
||||
assertEquals(4.seconds, delay(attempt = 3, jitter = 0.0))
|
||||
assertEquals(5.seconds, delay(attempt = 3, jitter = 1.0))
|
||||
|
||||
val midway = delay(attempt = 3, jitter = 0.5)
|
||||
assertTrue(midway > 4.seconds && midway < 5.seconds, "expected 4s..5s, got $midway")
|
||||
}
|
||||
|
||||
/**
|
||||
* Jitter is applied after the cap, so a capped delay still spreads: relays all drop at the
|
||||
* same moment when the network does, and identical waits would bring them back in lockstep
|
||||
* for as long as the outage lasted.
|
||||
*/
|
||||
@Test
|
||||
fun `jitter still spreads a capped delay`() {
|
||||
assertTrue(delay(attempt = 50, jitter = 1.0) > max)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package press.mantra.compose.nostr
|
||||
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Whether an encrypted group event actually leaves the device.
|
||||
*
|
||||
* The bug this pins did not throw, log, or fail a build. `MarmotOutboundDao`
|
||||
* wrote the broadcast rows inside `chatMessageOrNull?.let { }`, so an event with
|
||||
* no chat line pointing at it was MLS-encrypted, wrapped, persisted, its queue
|
||||
* row marked processed -- and never sent. FROST signing proposals and the room's
|
||||
* key announcement are exactly that: traffic the room generates, which
|
||||
* deliberately writes no ChatMessage of its own. Every proposal was silently
|
||||
* delivered to nobody.
|
||||
*
|
||||
* The DAO around this is Room-backed and cannot be stood up here, which is how
|
||||
* the gate survived unnoticed in the first place. So the decision is tested
|
||||
* where it can be seen, and the DAO does nothing with it but write what it says.
|
||||
*/
|
||||
class MarmotDeliveryTest {
|
||||
private val groupEventId = "a".repeat(64)
|
||||
private val relays = Relays.DefaultDMRelayList
|
||||
|
||||
private val chatLine = ChatMessage(
|
||||
id = 42L,
|
||||
senderPublicKey = "b".repeat(64),
|
||||
isUserMessage = true,
|
||||
giftWrapPayloadId = null,
|
||||
marmotGroupEventId = null,
|
||||
marmotInnerEventId = "c".repeat(64),
|
||||
chatRoomId = "room",
|
||||
content = "the vote is at six",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a signing proposal goes out, though nothing in the chat points at it`() {
|
||||
// The regression. A FROST message has no ChatMessage by design -- the manager
|
||||
// writes its own transcript lines from what arrives, so a row here would be a
|
||||
// second, worse account of the same thing -- and that must not be the reason the
|
||||
// group never hears about it.
|
||||
val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = null)
|
||||
|
||||
assertTrue(delivery.isProtocolTraffic)
|
||||
assertEquals(relays.size, delivery.broadcasts.size)
|
||||
assertTrue(delivery.broadcasts.isNotEmpty(), "an event on disk and going nowhere")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the send does not depend on the transcript`() {
|
||||
// Said as directly as it can be said: the two questions are independent. Anything
|
||||
// that makes a broadcast conditional on a chat line fails here.
|
||||
val protocol = MarmotDelivery.plan(groupEventId, relays, chatMessage = null)
|
||||
val spoken = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine)
|
||||
|
||||
assertContentEquals(
|
||||
protocol.broadcasts.map { it.relayURL },
|
||||
spoken.broadcasts.map { it.relayURL },
|
||||
)
|
||||
assertEquals(protocol.broadcasts.size, spoken.broadcasts.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every relay gets a request, naming the event`() {
|
||||
val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine)
|
||||
|
||||
assertContentEquals(
|
||||
relays.map { it.url },
|
||||
delivery.broadcasts.map { it.relayURL },
|
||||
)
|
||||
assertTrue(delivery.broadcasts.all { it.nostrEventId == groupEventId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requests are queued pending, which is all the broadcaster looks at`() {
|
||||
// `observeBroadcastNostrEventRequestsByStatus("pending")` is the only thing that
|
||||
// picks these up. A request written in any other state is as unsent as no request.
|
||||
val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = null)
|
||||
|
||||
assertTrue(delivery.broadcasts.all { it.status == "pending" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a member's message is linked to its chat line`() {
|
||||
// The bookkeeping that legitimately does depend on there being a chat line: the
|
||||
// transcript needs to know which event carried the words, so a sent message can
|
||||
// be shown as sent.
|
||||
val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine)
|
||||
|
||||
assertFalse(delivery.isProtocolTraffic)
|
||||
assertEquals(chatLine.id, delivery.transcriptChatMessageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no relays is the only way an event stays home`() {
|
||||
// Worth pinning as the single legitimate empty case, so an empty broadcast list
|
||||
// is always read as "nowhere to send it" and never as "nothing to send".
|
||||
val delivery = MarmotDelivery.plan(groupEventId, relays = emptyList(), chatMessage = chatLine)
|
||||
|
||||
assertTrue(delivery.broadcasts.isEmpty())
|
||||
assertEquals(chatLine.id, delivery.transcriptChatMessageId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package press.mantra.compose.nostr
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
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.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What a device decides to do with an arriving wrap.
|
||||
*
|
||||
* This is where the check that replaces MIP-03 for kind:1059 is tested. The wrap itself
|
||||
* names nobody, so everything a transcript says about who sent a private message rests on
|
||||
* one comparison -- the seal's author against the identity MLS authenticated -- and if it
|
||||
* stops being made, a member can have their words attributed to somebody else, or
|
||||
* somebody else's attributed to them. The rest of the inbound path is Room-backed and
|
||||
* cannot be unit-tested here, which is exactly why the decision was separated from the
|
||||
* filing of it.
|
||||
*
|
||||
* `Ours` carries a second kind of risk. It is indistinguishable from `Unreadable` by
|
||||
* decryption alone -- we cannot open our own wrap either -- and getting it wrong does not
|
||||
* fail loudly: it files a placeholder over the only copy of the sender's own words.
|
||||
*/
|
||||
class MarmotDirectMessageDeliveryTest {
|
||||
private val alice = NostrSignerSync(KeyPair())
|
||||
private val bob = KeyPair()
|
||||
private val eve = NostrSignerSync(KeyPair())
|
||||
|
||||
private val alicePublicKey = alice.pubKey
|
||||
private val bobPublicKey = bob.pubKey.toHexKey()
|
||||
|
||||
private val at = 1_700_000_000L
|
||||
private val text = "the vote is at six"
|
||||
|
||||
private fun wrapFor(
|
||||
signer: NostrSignerSync = alice,
|
||||
recipient: String = bobPublicKey,
|
||||
kind: Int = ChatMessageEvent.KIND,
|
||||
content: String = text,
|
||||
): GiftWrapEvent =
|
||||
MarmotDirectMessage.wrap(
|
||||
signer = signer,
|
||||
recipientPublicKey = recipient,
|
||||
kind = kind,
|
||||
createdAt = at,
|
||||
tags = arrayOf(PTag.assemble(recipient, null)),
|
||||
content = content,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the recipient gets the words`() {
|
||||
val delivery = MarmotDirectMessage.classify(wrapFor(), bob, alicePublicKey)
|
||||
|
||||
val readable = assertIs<MarmotDirectMessage.Delivery.Readable>(delivery)
|
||||
assertEquals(text, readable.opened.rumor.content)
|
||||
assertEquals(alicePublicKey, readable.opened.seal.pubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bystander gets a line with nothing in it`() {
|
||||
val carol = KeyPair()
|
||||
|
||||
assertEquals(
|
||||
MarmotDirectMessage.Delivery.Unreadable,
|
||||
MarmotDirectMessage.classify(wrapFor(), carol, alicePublicKey),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `our own message is ours, even though we cannot open it`() {
|
||||
// The case that destroys data if it is missed. Alice cannot decrypt the wrap she
|
||||
// sent -- its key was discarded -- so nothing about the payload distinguishes this
|
||||
// from a bystander's view. Only the MLS sender identity does. Were it to come back
|
||||
// Unreadable, the inbound path would file an empty line over the row sendChatMessage
|
||||
// wrote, which is the only copy of those words anywhere.
|
||||
val delivery = MarmotDirectMessage.classify(wrapFor(), alice.keyPair, alicePublicKey)
|
||||
|
||||
assertEquals(MarmotDirectMessage.Delivery.Ours, delivery)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ours is decided before anything is opened`() {
|
||||
// Same as above, from the other direction: a wrap Alice could never open, that is
|
||||
// not even addressed to her, is still hers if MLS says she sent it. Nothing here
|
||||
// may depend on decryption succeeding.
|
||||
val toEve = wrapFor(recipient = eve.pubKey)
|
||||
|
||||
assertEquals(
|
||||
MarmotDirectMessage.Delivery.Ours,
|
||||
MarmotDirectMessage.classify(toEve, alice.keyPair, alicePublicKey),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message with no sender identity is dropped`() {
|
||||
// Attribution has no other source. A line that cannot say who sent it must not be
|
||||
// rendered at all rather than be attributed to the wrap, which names nobody.
|
||||
val delivery = MarmotDirectMessage.classify(wrapFor(), bob, null)
|
||||
|
||||
assertIs<MarmotDirectMessage.Delivery.Rejected>(delivery)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `words sealed by one member and sent by another are dropped`() {
|
||||
// The check that replaces MIP-03. Eve seals to Bob and sends; if MLS says the
|
||||
// sender was Alice, the two disagree about who is speaking and the message is not
|
||||
// rendered as anybody's. Without this, the seal inside is free to claim an author
|
||||
// the frame does not support.
|
||||
val delivery = MarmotDirectMessage.classify(wrapFor(signer = eve), bob, alicePublicKey)
|
||||
|
||||
val rejected = assertIs<MarmotDirectMessage.Delivery.Rejected>(delivery)
|
||||
assertTrue(
|
||||
rejected.reason.contains(eve.pubKey) && rejected.reason.contains(alicePublicKey),
|
||||
"the reason should name both parties to the disagreement: ${rejected.reason}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seal relabelled with another author cannot even be opened`() {
|
||||
// Eve's seal with Alice's pubkey written over it. This never reaches the author
|
||||
// check, because NIP-44 derives the conversation key from the pubkey being
|
||||
// claimed: relabelling a seal makes it undecryptable by the person it was
|
||||
// encrypted for. The label is bound to the key, not merely asserted alongside it.
|
||||
//
|
||||
// So the outcome is Unreadable rather than Rejected -- Bob genuinely cannot read
|
||||
// it -- and the group sees a private message it cannot open, which is the truth.
|
||||
val forged = resealedAs(alicePublicKey, sealedBy = eve, to = bob)
|
||||
|
||||
assertEquals(
|
||||
MarmotDirectMessage.Delivery.Unreadable,
|
||||
MarmotDirectMessage.classify(forged, bob, alicePublicKey),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seal tampered with after signing is dropped`() {
|
||||
// Alice's own seal with its timestamp altered. Everything the previous test
|
||||
// relies on still holds -- the pubkey is Alice's, so the content decrypts -- and
|
||||
// the pubkey matches what MLS says, so the author check passes. Only the
|
||||
// signature is left, and it is what catches this: the id no longer commits to the
|
||||
// fields, so verify() fails.
|
||||
val tampered = tamperedTimestamp(sealedBy = alice, to = bob)
|
||||
|
||||
val delivery = MarmotDirectMessage.classify(tampered, bob, alicePublicKey)
|
||||
|
||||
val rejected = assertIs<MarmotDirectMessage.Delivery.Rejected>(delivery)
|
||||
assertTrue(
|
||||
rejected.reason.contains("does not verify"),
|
||||
"expected a signature rejection, got: ${rejected.reason}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rumor by somebody other than the sealer is dropped`() {
|
||||
// A correctly signed seal by Alice, wrapped around a rumor claiming to be Eve's.
|
||||
// Everything outside the innermost layer checks out; the words would be filed
|
||||
// under whoever the rumor names if this were not caught.
|
||||
val mismatched = sealAroundForeignRumor(sealedBy = alice, rumorFrom = eve.pubKey, to = bobPublicKey)
|
||||
|
||||
val delivery = MarmotDirectMessage.classify(mismatched, bob, alicePublicKey)
|
||||
|
||||
val rejected = assertIs<MarmotDirectMessage.Delivery.Rejected>(delivery)
|
||||
assertTrue(
|
||||
rejected.reason.contains("inside a seal by"),
|
||||
"expected a rumor/seal author mismatch, got: ${rejected.reason}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rumor of the wrong kind is dropped`() {
|
||||
// kind:9 is what an ordinary group message uses. Arriving gift wrapped it is not a
|
||||
// direct message this version knows how to render, and rendering it as one would
|
||||
// put a group message behind a lock.
|
||||
val delivery = MarmotDirectMessage.classify(wrapFor(kind = ChatEvent.KIND), bob, alicePublicKey)
|
||||
|
||||
val rejected = assertIs<MarmotDirectMessage.Delivery.Rejected>(delivery)
|
||||
assertTrue(
|
||||
rejected.reason.contains("${ChatEvent.KIND}"),
|
||||
"expected the offending kind in the reason, got: ${rejected.reason}",
|
||||
)
|
||||
}
|
||||
|
||||
/** [sealedBy]'s seal with [claimedAuthor] written over its pubkey, wrapped for [to]. */
|
||||
private fun resealedAs(
|
||||
claimedAuthor: String,
|
||||
sealedBy: NostrSignerSync,
|
||||
to: KeyPair,
|
||||
): GiftWrapEvent {
|
||||
val honest = MarmotDirectMessage.wrap(
|
||||
signer = sealedBy,
|
||||
recipientPublicKey = to.pubKey.toHexKey(),
|
||||
kind = ChatMessageEvent.KIND,
|
||||
createdAt = at,
|
||||
tags = emptyArray(),
|
||||
content = text,
|
||||
)
|
||||
val seal = MarmotDirectMessage.open(honest, to)!!.seal
|
||||
|
||||
val relabelled =
|
||||
Event(
|
||||
id = seal.id,
|
||||
pubKey = claimedAuthor,
|
||||
createdAt = seal.createdAt,
|
||||
kind = SealedRumorEvent.KIND,
|
||||
tags = seal.tags,
|
||||
content = seal.content,
|
||||
sig = seal.sig,
|
||||
)
|
||||
|
||||
return GiftWrapEvent.create(
|
||||
event = relabelled,
|
||||
recipientPubKey = to.pubKey.toHexKey(),
|
||||
createdAt = at,
|
||||
)
|
||||
}
|
||||
|
||||
/** [sealedBy]'s own seal, its timestamp altered after signing, wrapped for [to]. */
|
||||
private fun tamperedTimestamp(
|
||||
sealedBy: NostrSignerSync,
|
||||
to: KeyPair,
|
||||
): GiftWrapEvent {
|
||||
val honest = MarmotDirectMessage.wrap(
|
||||
signer = sealedBy,
|
||||
recipientPublicKey = to.pubKey.toHexKey(),
|
||||
kind = ChatMessageEvent.KIND,
|
||||
createdAt = at,
|
||||
tags = emptyArray(),
|
||||
content = text,
|
||||
)
|
||||
val seal = MarmotDirectMessage.open(honest, to)!!.seal
|
||||
|
||||
val altered =
|
||||
Event(
|
||||
id = seal.id,
|
||||
pubKey = seal.pubKey,
|
||||
createdAt = seal.createdAt + 86_400,
|
||||
kind = SealedRumorEvent.KIND,
|
||||
tags = seal.tags,
|
||||
content = seal.content,
|
||||
sig = seal.sig,
|
||||
)
|
||||
|
||||
return GiftWrapEvent.create(
|
||||
event = altered,
|
||||
recipientPubKey = to.pubKey.toHexKey(),
|
||||
createdAt = at,
|
||||
)
|
||||
}
|
||||
|
||||
/** A properly signed seal by [sealedBy] whose rumor claims [rumorFrom] wrote it. */
|
||||
private fun sealAroundForeignRumor(
|
||||
sealedBy: NostrSignerSync,
|
||||
rumorFrom: String,
|
||||
to: String,
|
||||
): GiftWrapEvent {
|
||||
val rumor = MarmotDirectMessage.rumor(rumorFrom, ChatMessageEvent.KIND, at, emptyArray(), text)
|
||||
|
||||
val seal =
|
||||
sealedBy.signNormal<SealedRumorEvent>(
|
||||
createdAt = at,
|
||||
kind = SealedRumorEvent.KIND,
|
||||
tags = emptyArray(),
|
||||
content = sealedBy.nip44Encrypt(rumor.toJson(), to),
|
||||
)
|
||||
|
||||
return GiftWrapEvent.create(event = seal, recipientPubKey = to, createdAt = at)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package press.mantra.compose.nostr
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The envelope a direct message travels in, run against real secp256k1 and real NIP-44.
|
||||
*
|
||||
* Two of these assert properties that read like bugs and are not. The wrap names nobody,
|
||||
* so the group can only learn who sent it from the MLS frame around it; and the sender
|
||||
* cannot reopen their own message, because the key that sealed it was discarded. Both are
|
||||
* consequences of using a throwaway key, both are documented in
|
||||
* docs/marmot-direct-messages.md, and both are here so that a later change which quietly
|
||||
* reverses them fails rather than ships.
|
||||
*/
|
||||
class MarmotDirectMessageTest {
|
||||
private val alice = NostrSignerSync(KeyPair())
|
||||
private val bob = KeyPair()
|
||||
private val eve = KeyPair()
|
||||
|
||||
private val bobPublicKey = bob.pubKey.toHexKey()
|
||||
private val at = 1_700_000_000L
|
||||
private val text = "the vote is at six, do not tell the room"
|
||||
|
||||
private fun aliceWrapsForBob(content: String = text): GiftWrapEvent =
|
||||
MarmotDirectMessage.wrap(
|
||||
signer = alice,
|
||||
recipientPublicKey = bobPublicKey,
|
||||
kind = ChatMessageEvent.KIND,
|
||||
createdAt = at,
|
||||
tags = arrayOf(PTag.assemble(bobPublicKey, null)),
|
||||
content = content,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the recipient reads what the sender wrote`() {
|
||||
val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob), "Bob could not open a wrap addressed to him")
|
||||
|
||||
assertEquals(text, opened.rumor.content)
|
||||
assertEquals(alice.pubKey, opened.rumor.pubKey)
|
||||
assertEquals(ChatMessageEvent.KIND, opened.rumor.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sender cannot reopen their own message`() {
|
||||
// The throwaway key is gone, so this is unrecoverable by construction. Asserted
|
||||
// rather than merely documented, because the obvious "fix" -- persisting the
|
||||
// throwaway private key -- would be strictly worse than the identity-keyed wrap
|
||||
// this design was chosen over, and would reintroduce the attribution the
|
||||
// throwaway key exists to remove.
|
||||
assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), alice.keyPair))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bystander gets nothing, and no exception`() {
|
||||
// Null rather than a throw: the caller is midway through processing a kind:445
|
||||
// that carries a real message for somebody, and an exception would abandon it.
|
||||
assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), eve))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wrap names nobody`() {
|
||||
val first = aliceWrapsForBob()
|
||||
val second = aliceWrapsForBob()
|
||||
|
||||
assertNotEquals(alice.pubKey, first.pubKey, "the wrap is keyed to its sender")
|
||||
assertNotEquals(bobPublicKey, first.pubKey, "the wrap is keyed to its recipient")
|
||||
assertNotEquals(first.pubKey, second.pubKey, "the throwaway key is being reused")
|
||||
|
||||
// Signed by the throwaway key, per NIP-59. The signature attributes nothing --
|
||||
// the signer is meaningless and discarded -- and keeping it is what makes this a
|
||||
// real gift wrap that GiftWrapEvent.create builds and unwrapOrNull opens.
|
||||
assertTrue(first.verify(), "the wrap does not verify against its own key")
|
||||
assertEquals(GiftWrapEvent.KIND, first.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the seal is what binds the words to their author`() {
|
||||
val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob))
|
||||
|
||||
assertEquals(SealedRumorEvent.KIND, opened.seal.kind)
|
||||
// The inbound path checks exactly this pair against the MLS sender identity. It
|
||||
// is what replaces the MIP-03 pubkey check for kind:1059, so if the seal stops
|
||||
// being signed by the sender, the carve-out becomes a hole.
|
||||
assertEquals(alice.pubKey, opened.seal.pubKey)
|
||||
assertTrue(opened.seal.verify(), "the seal's signature does not verify")
|
||||
assertEquals(opened.seal.pubKey, opened.rumor.pubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seal from somebody else is still opened, and is caught by its pubkey`() {
|
||||
// open() decrypts; it does not adjudicate. Eve can seal her own rumor to Bob and
|
||||
// wrap it, and Bob's key will open it -- what stops it being rendered as Alice's
|
||||
// is the inbound check that the seal's pubkey is the MLS sender's identity. This
|
||||
// test pins the half open() is responsible for: the pubkey it reports is Eve's.
|
||||
val eveSigner = NostrSignerSync(eve)
|
||||
val forged =
|
||||
MarmotDirectMessage.wrap(
|
||||
signer = eveSigner,
|
||||
recipientPublicKey = bobPublicKey,
|
||||
kind = ChatMessageEvent.KIND,
|
||||
createdAt = at,
|
||||
tags = arrayOf(PTag.assemble(bobPublicKey, null)),
|
||||
content = "alice here, send the funds",
|
||||
)
|
||||
|
||||
val opened = assertNotNull(MarmotDirectMessage.open(forged, bob))
|
||||
|
||||
assertEquals(eveSigner.pubKey, opened.seal.pubKey)
|
||||
assertNotEquals(alice.pubKey, opened.seal.pubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the rumor id is the one the sender queued`() {
|
||||
val tags = arrayOf(PTag.assemble(bobPublicKey, null))
|
||||
|
||||
// What the outbound queue computes before it has a signer, and what it later uses
|
||||
// to find the chat message to link and broadcast. If these ever diverge the
|
||||
// message is encrypted, stored, and silently never sent.
|
||||
val queued =
|
||||
EventHasher.hashId(
|
||||
alice.pubKey,
|
||||
at,
|
||||
ChatMessageEvent.KIND,
|
||||
tags,
|
||||
text,
|
||||
)
|
||||
|
||||
val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob))
|
||||
|
||||
assertEquals(queued, opened.rumor.id)
|
||||
assertEquals(queued, MarmotDirectMessage.rumor(alice.pubKey, ChatMessageEvent.KIND, at, tags, text).id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no layer is fuzzed into the past`() {
|
||||
val wrap = aliceWrapsForBob()
|
||||
val opened = assertNotNull(MarmotDirectMessage.open(wrap, bob))
|
||||
|
||||
// NIP-59 defaults every one of these to randomWithTwoDays(). Inside MLS that only
|
||||
// scatters the bystander line up to two days out of position in every other
|
||||
// member's transcript, so all three carry the real message time.
|
||||
assertEquals(at, wrap.createdAt)
|
||||
assertEquals(at, opened.seal.createdAt)
|
||||
assertEquals(at, opened.rumor.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the recipient is on the wrap where the group can see it`() {
|
||||
val wrap = aliceWrapsForBob()
|
||||
|
||||
// Deliberate: it is what lets a bystander's transcript say who the message was
|
||||
// for. Removing it would hide the recipient from the group at the cost of the
|
||||
// named line -- see the decisions table in docs/marmot-direct-messages.md.
|
||||
assertEquals(bobPublicKey, wrap.tags.firstTagValue("p"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage in the wrap does not become a message`() {
|
||||
val notAWrap =
|
||||
alice.signNormal<Event>(
|
||||
createdAt = at,
|
||||
kind = GiftWrapEvent.KIND,
|
||||
tags = arrayOf(PTag.assemble(bobPublicKey, null)),
|
||||
content = "not nip-44 at all",
|
||||
)
|
||||
|
||||
assertNull(MarmotDirectMessage.open(notAWrap, bob))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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.assertNull
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
|
||||
/**
|
||||
* What the form collects has to reach the members deciding whether to sign it.
|
||||
*
|
||||
* An artifact proposal leaves the proposer's device as a kind, a tag array and
|
||||
* a string, and everything a member is shown before signing -- and every row
|
||||
* built afterwards -- is read back out of those. A field that does not survive
|
||||
* the trip is not a visible failure: the artifact still appears, just without
|
||||
* a url, or a source dialect, or a first version, on every device but the one
|
||||
* that typed it.
|
||||
*/
|
||||
class ArtifactEventTest {
|
||||
private val groupKey = "a".repeat(64)
|
||||
private val dialectId = "b".repeat(64)
|
||||
|
||||
/** The event a member actually receives: bytes, with no template behind it. */
|
||||
private fun readBack(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ArtifactEvent>) =
|
||||
ArtifactEvent(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "c".repeat(128),
|
||||
)
|
||||
|
||||
private fun proposal(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,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `every field the form collects survives the trip through the tags`() {
|
||||
val artifact = readBack(proposal())
|
||||
|
||||
assertEquals("In Detention", artifact.content)
|
||||
assertEquals("https://example.com/in-detention", artifact.url())
|
||||
assertEquals("private", artifact.visibility())
|
||||
assertEquals("cc", artifact.license())
|
||||
assertEquals(dialectId, artifact.dialectId())
|
||||
assertEquals("1.0", artifact.versionLabel())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the proposal is the artifact's kind, so the signing screen can describe it`() {
|
||||
// The session carries a kind and nothing else to go on. Get this wrong
|
||||
// and members are asked to sign "event of kind 30300" -- a question
|
||||
// nobody can answer.
|
||||
assertEquals(ArtifactEvent.KIND, proposal().kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact declares one version, whatever an initializer adds`() {
|
||||
// addUnique, not add: two labels would leave receivers deriving two
|
||||
// different first versions depending on which one they read first.
|
||||
val template = ArtifactEvent.build(
|
||||
name = "In Detention",
|
||||
url = "https://example.com/in-detention",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = dialectId,
|
||||
versionLabel = "1.0",
|
||||
) {
|
||||
addUnique(ArtifactVersionMetadataTag.assemble("2.0"))
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
1,
|
||||
template.tags.count { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact from before the label existed reads back as declaring none`() {
|
||||
// Not an error: it is what every artifact submitted the old way looks
|
||||
// like, and they have to keep parsing rather than failing to load.
|
||||
val template = proposal()
|
||||
val older = Event(
|
||||
id = "d".repeat(64),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags
|
||||
.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
|
||||
.toTypedArray(),
|
||||
content = template.content,
|
||||
sig = "",
|
||||
)
|
||||
|
||||
val artifact = ArtifactEvent(
|
||||
older.id, older.pubKey, older.createdAt, older.tags, older.content, older.sig
|
||||
)
|
||||
|
||||
assertNull(artifact.versionLabel())
|
||||
// Everything else still reads, so the artifact itself is unharmed.
|
||||
assertEquals(dialectId, artifact.dialectId())
|
||||
assertEquals("https://example.com/in-detention", artifact.url())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user