Merge branch 'mantra' into claude/long-running-chat-sync-8983dc
mantra had moved on ~30 commits, several of them in exactly this area — and it turns out both branches independently found the same bug and drew the same conclusion about the same filter. **The overlap.**f38a5f1fixed the three kind:1059 filters that named the wrong pubkey, including the two `authors=[userPublicKey]` requests in NostrDao that could never match a wrap signed by a throwaway key. This branch deleted those same two blocks, inverting the same `if` to the `== null` case, for the same reason. The code merged to the same shape; only the comments conflicted, and they are combined. **Nip17Filters wins, and the live subscription now defers to it.**ad3304aextracted the inbox filter to one definition precisely because it had been wrong in three call sites, with the no-`since` reasoning this branch arrived at separately. Keeping a fourth copy inside LiveSubscriptionManager would recreate the problem that commit exists to solve, so: - queueCatchUpSynchronization now calls Nip17Filters.inbox() instead of building an identical SynchronizationFilter with its own limit constant, - Nip17Filters gains liveInbox(), the same shape as a quartz Filter for a REQ rather than a SynchronizationFilter for the queue, and giftWrapFilter() defers to it. Two types for one filter is not duplication worth removing — the queue stores one and hashes it for computeId, a live subscription puts the other on the wire — but they belong side by side, because drift here means one of them quietly stops matching mail. **ChatMessageListViewModel keeps this branch's resolution.** mantra had it refresh our own inbox on open (Nip17Filters.inbox on our DM relays, purpose "chat"); this branch removed that call entirely. Both were right when written, and the merge is where the second becomes true: LiveSubscriptionManager holds exactly that filter open on exactly those relays for the whole account and reconciles it on every foreground, so opening a chat has nothing left to ask for. The redundancy is now recorded in the comment where the branch used to be, so it reads as superseded rather than dropped. Discovery — the kind-10050 lookup for a participant we cannot yet address — is untouched, and the purpose is no longer a conditional now that only one case reaches it. The commonTest coroutines-test dependency arrived on both sides; the comment gives both reasons. Verified: 154 tests pass, both branches' suites included — Nip17FiltersTest and the marmot direct-message suites alongside this branch's 46. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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,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,174 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
|
||||
|
||||
/**
|
||||
* The row on disk and the payload on the wire have to be the same event.
|
||||
*
|
||||
* `MantraDao` writes an entity whose id comes from `MantraX.fromXEventTemplate`,
|
||||
* and separately builds the rumor it submits with `rumorOf`, which hashes the
|
||||
* template itself. Both are supposed to produce one id. Nothing checks that they
|
||||
* do, and nothing would notice if they stopped:
|
||||
*
|
||||
* - the submission would carry a `payloadId` naming an event nobody has,
|
||||
* - `MarmotInnerEvent.payloadEventId` would stop matching the row it carries,
|
||||
* so `deleteByPayloadEventId` would silently un-queue nothing and superseded
|
||||
* translations would go out anyway,
|
||||
* - and every receiver would create a *second* row rather than converging on
|
||||
* the sender's, because entity ids are content hashes and the two sides would
|
||||
* be hashing different things.
|
||||
*
|
||||
* All of that is silent. The ids are opaque hex either way.
|
||||
*/
|
||||
class RumorIdAgreementTest {
|
||||
private val author = "a".repeat(64)
|
||||
private val chatRoomId = "room"
|
||||
private val other = "b".repeat(64)
|
||||
|
||||
/** Exactly what `MantraDao.rumorOf` does, and it must stay exactly that. */
|
||||
private fun rumorIdOf(template: EventTemplate<*>): String = EventHasher.hashId(
|
||||
pubKey = author,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a dialect's row and its rumor agree`() {
|
||||
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
|
||||
|
||||
val entity = MantraDialect.fromDialectEventTemplate(
|
||||
dialectEventTemplate = template,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = author
|
||||
)
|
||||
|
||||
assertEquals(rumorIdOf(template), entity?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact's row and its rumor agree`() {
|
||||
val template = ArtifactEvent.build(
|
||||
name = "In Detention",
|
||||
url = "example.com",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = other
|
||||
)
|
||||
|
||||
val entity = MantraArtifact.fromArtifactEventTemplate(
|
||||
artifactEventTemplate = template,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = author
|
||||
)
|
||||
|
||||
assertEquals(rumorIdOf(template), entity?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact version's row and its rumor agree`() {
|
||||
val template = ArtifactVersionEvent.build(content = "1.0") {
|
||||
addUnique(ArtifactIdTag.assemble(other))
|
||||
}
|
||||
|
||||
val entity = MantraArtifactVersion.fromArtifactVersionEventTemplate(
|
||||
artifactVersionEventTemplate = template,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = author
|
||||
)
|
||||
|
||||
assertEquals(rumorIdOf(template), entity?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chapter's and a chunk's rows agree with their rumors`() {
|
||||
val chapter = ChapterEvent.build(
|
||||
artifactVersionId = other,
|
||||
name = "Chapter 1",
|
||||
originalText = "some text",
|
||||
index = 0,
|
||||
wordCount = 2,
|
||||
characterCount = 9
|
||||
)
|
||||
val chunk = ChunkEvent.build(
|
||||
chapterId = other,
|
||||
text = "some text",
|
||||
index = 0,
|
||||
wordCount = 2,
|
||||
characterCount = 9
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
rumorIdOf(chapter),
|
||||
MantraChapter.fromChapterEventTemplate(chapter, chatRoomId, author)?.id
|
||||
)
|
||||
assertEquals(
|
||||
rumorIdOf(chunk),
|
||||
MantraChunk.fromChunkEventTemplate(chunk, chatRoomId, author)?.id
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a translation version's row and its rumor agree`() {
|
||||
val template = TranslationArtifactVersionEvent.build(
|
||||
artifactVersionId = other,
|
||||
dialectId = other,
|
||||
name = "Sesotho",
|
||||
visibility = "private",
|
||||
license = "cc"
|
||||
)
|
||||
|
||||
val entity = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate(
|
||||
translationArtifactVersionEventTemplate = template,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = author
|
||||
)
|
||||
|
||||
assertEquals(rumorIdOf(template), entity?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the submission names the id the row was written under`() {
|
||||
// The end of the chain the rest of this file checks a link of: what a
|
||||
// receiver reads out of the envelope has to be the id the sender stored.
|
||||
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
|
||||
val entity = MantraDialect.fromDialectEventTemplate(template, chatRoomId, author)
|
||||
|
||||
val payload = Event(
|
||||
id = rumorIdOf(template),
|
||||
pubKey = author,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = ""
|
||||
)
|
||||
val submission = SubmissionEvent.build(payload = payload)
|
||||
|
||||
val readBack = SubmissionEvent(
|
||||
id = "f".repeat(64),
|
||||
pubKey = author,
|
||||
createdAt = submission.createdAt,
|
||||
tags = submission.tags,
|
||||
content = submission.content,
|
||||
sig = ""
|
||||
)
|
||||
|
||||
assertEquals(entity?.id, readBack.payloadId())
|
||||
assertEquals(entity?.id, readBack.payload()?.id)
|
||||
assertEquals(DialectEvent.KIND, readBack.payloadKind())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.crypto.frost.Frost
|
||||
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
|
||||
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import fr.acinq.bitcoin.crypto.frost.SecretNonce
|
||||
import fr.acinq.bitcoin.crypto.frost.Session
|
||||
import fr.acinq.bitcoin.crypto.frost.TweakCache
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
|
||||
/**
|
||||
* The two rounds a signing session runs, against real FROST.
|
||||
*
|
||||
* `FrostSigningManager` spreads these steps across arriving messages, several
|
||||
* devices and a database, none of which a unit test can stand up. What it can
|
||||
* do is run the same calls in the same order with the same arguments and check
|
||||
* that what comes out is a signature nostr will accept — which is the part
|
||||
* that was written from reading the library rather than from a working example,
|
||||
* and so the part most likely to be subtly wrong.
|
||||
*
|
||||
* A signature that verifies is the whole contract: if these calls are wired up
|
||||
* incorrectly the aggregate simply fails to verify, silently, on every device.
|
||||
*/
|
||||
class FrostSigningRoundTest {
|
||||
private val participants = 3
|
||||
private val threshold = 2
|
||||
|
||||
/** Stands in for a completed ceremony. A trusted dealer is fine here: the test is about signing. */
|
||||
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
|
||||
),
|
||||
nParticipants = participants,
|
||||
threshold = threshold
|
||||
)
|
||||
|
||||
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
|
||||
|
||||
/** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */
|
||||
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
|
||||
|
||||
/** The 32 bytes actually signed — a nostr event id, exactly as the manager computes it. */
|
||||
private fun eventId(content: String): String = EventHasher.hashId(
|
||||
pubKey = groupPubKey,
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = 1,
|
||||
tags = arrayOf(),
|
||||
content = content
|
||||
)
|
||||
|
||||
/**
|
||||
* One signer's half of the protocol, in the manager's order: regenerate the
|
||||
* nonce from stored randomness, then sign once the set is known.
|
||||
*/
|
||||
private fun nonceOf(signerId: Int, message: ByteVector, random: String): Pair<SecretNonce, IndividualNonce> =
|
||||
SecretNonce.generate(
|
||||
sessionRandom = ByteVector32(random),
|
||||
secretShare = keyMaterial.secretShares[signerId],
|
||||
publicShare = keyMaterial.publicShares[signerId],
|
||||
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
|
||||
message = message,
|
||||
extraInput = null
|
||||
)
|
||||
|
||||
private fun sessionFor(signerIds: List<Int>, nonces: List<IndividualNonce>, message: ByteVector): Session {
|
||||
val aggregated = IndividualNonce.aggregate(nonces).right!!
|
||||
|
||||
return Session.create(
|
||||
aggregatedNonce = aggregated,
|
||||
signerIds = signerIds.map { it.toUInt() },
|
||||
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
|
||||
nParticipants = participants,
|
||||
threshold = threshold,
|
||||
tweakCache = tweakCache,
|
||||
message = message
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a threshold of signers produces a signature nostr accepts`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
// Two of the three sign, which is the point of a 2-of-3 key.
|
||||
val signerIds = listOf(0, 1)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "a".repeat(63) + "${it + 1}") }
|
||||
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = id.hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
),
|
||||
"the aggregated signature must verify against the group's x-only key"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different pair of signers signs the same event just as well`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
// Whoever happens to be available. The coordinator picks; the signature
|
||||
// that comes out must not depend on which t it picked.
|
||||
val signerIds = listOf(1, 2)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "b".repeat(63) + "${it + 1}") }
|
||||
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = id.hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signature over one event does not verify against another`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
val signerIds = listOf(0, 1)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "c".repeat(63) + "${it + 1}") }
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertFalse(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = eventId("the group agrees to something else").hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
),
|
||||
"a signature is over one event id and must not carry to another"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `regenerating a nonce from the same seed and message gives the same nonce`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
val random = "d".repeat(63) + "1"
|
||||
|
||||
// What makes a signing session restart-safe: SecretNonce cannot be stored,
|
||||
// so the manager keeps its seed and derives again. If that were not
|
||||
// reproducible a device that restarted mid-session would publish a partial
|
||||
// signature against a nonce nobody aggregated.
|
||||
val first = nonceOf(0, message, random).second
|
||||
val second = nonceOf(0, message, random).second
|
||||
|
||||
assertEquals(first.data.toHex(), second.data.toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same seed under a different message gives a different nonce`() {
|
||||
val random = "e".repeat(63) + "1"
|
||||
|
||||
// The safety property behind reusing the seed at all: one session signs one
|
||||
// message. Were the nonce independent of the message, a session that could
|
||||
// be re-pointed at another event would sign twice under one nonce, which
|
||||
// hands over the secret share.
|
||||
val first = nonceOf(0, ByteVector(eventId("one thing").hexToByteArray()), random).second
|
||||
val second = nonceOf(0, ByteVector(eventId("another thing").hexToByteArray()), random).second
|
||||
|
||||
assertFalse(first.data.toHex() == second.data.toHex())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure bits of a signing session's bookkeeping: who is signing, and with
|
||||
* which key.
|
||||
*/
|
||||
class FrostSigningSessionTest {
|
||||
private fun session(signerId: Int, signerIds: String?) = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = "room",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
userPublicKey = "u".repeat(64),
|
||||
dkgSessionId = "k".repeat(64),
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
signerId = signerId,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "e".repeat(64),
|
||||
nonceRandom = "f".repeat(64),
|
||||
signerIds = signerIds
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a member left out of the signer set is not a signer`() {
|
||||
assertTrue(session(signerId = 1, signerIds = "0,1").isSigner())
|
||||
assertFalse(session(signerId = 2, signerIds = "0,1").isSigner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nobody is a signer until the coordinator has chosen`() {
|
||||
assertFalse(session(signerId = 0, signerIds = null).isSigner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the signer set keeps the order it was aggregated in`() {
|
||||
// FROST binds the set into the challenge, so this list is not a set of ids
|
||||
// but a sequence positionally matched to the aggregated nonce.
|
||||
assertEquals(listOf(2, 0, 1), session(signerId = 0, signerIds = "2,0,1").signerIdList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signer set tag survives the trip through a tag array`() {
|
||||
val tags = FrostSigningEvents.assembleTags(
|
||||
sessionId = "session",
|
||||
dkgSessionId = "ceremony",
|
||||
signerIds = listOf(2, 0, 1)
|
||||
)
|
||||
|
||||
assertEquals("session", FrostSigningEvents.parseSessionId(tags))
|
||||
assertEquals("ceremony", FrostSigningEvents.parseKey(tags))
|
||||
assertEquals(listOf(2, 0, 1), FrostSigningEvents.parseSignerIds(tags))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ceremony that recorded no public shares reads back null rather than empty`() {
|
||||
// Ceremonies completed before the column existed. Signing falls back to not
|
||||
// cross-checking shares, which the FROST API allows, rather than refusing.
|
||||
val ceremony = DkgSession(
|
||||
id = "k".repeat(64),
|
||||
chatRoomId = "room",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
userPublicKey = "u".repeat(64),
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
hostPublicKey = "h".repeat(66),
|
||||
round1Random = "1".repeat(64),
|
||||
round2AuxRandom = "2".repeat(64)
|
||||
)
|
||||
|
||||
assertEquals(null, ceremony.publicShareList())
|
||||
assertEquals(
|
||||
2,
|
||||
ceremony.copy(
|
||||
publicShares = listOf(
|
||||
Hex.encode(ByteArray(33) { 2 }),
|
||||
Hex.encode(ByteArray(33) { 3 })
|
||||
).joinToString(",")
|
||||
).publicShareList()?.size
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,126 @@
|
||||
package press.mantra.compose.nostr.nip30303
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* What a submission has to survive: the trip through a group.
|
||||
*
|
||||
* The envelope is only worth having if the event inside it comes out the other
|
||||
* side unchanged -- same id, same author, same signature. The moment any of
|
||||
* those is rewritten in transit, a group can no longer hold work by anyone but
|
||||
* its own members, which is the whole reason submissions exist.
|
||||
*/
|
||||
class SubmissionEventTest {
|
||||
private val submitter = "a".repeat(64)
|
||||
private val outsider = "b".repeat(64)
|
||||
|
||||
/** A dialect written by somebody who is not in the group. */
|
||||
private fun outsiderDialect(): Event {
|
||||
val template = DialectEvent.build(
|
||||
name = "Sesotho",
|
||||
country = "Lesotho",
|
||||
language = "st",
|
||||
createdAt = 1_700_000_000L,
|
||||
)
|
||||
return Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = outsider,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = outsider,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "c".repeat(128),
|
||||
)
|
||||
}
|
||||
|
||||
/** Send a submission template and read it back the way inbound does. */
|
||||
private fun roundTrip(payload: Event): SubmissionEvent {
|
||||
val template = SubmissionEvent.build(payload = payload, createdAt = 1_700_000_100L)
|
||||
val onTheWire = Event.fromJson(
|
||||
Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = submitter,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = submitter,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "",
|
||||
).toJson()
|
||||
)
|
||||
|
||||
return SubmissionEvent(
|
||||
id = onTheWire.id,
|
||||
pubKey = onTheWire.pubKey,
|
||||
createdAt = onTheWire.createdAt,
|
||||
tags = onTheWire.tags,
|
||||
content = onTheWire.content,
|
||||
sig = onTheWire.sig,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the payload comes back as the event that went in`() {
|
||||
val dialect = outsiderDialect()
|
||||
|
||||
val payload = roundTrip(dialect).payload()
|
||||
|
||||
assertEquals(dialect.id, payload?.id)
|
||||
assertEquals(dialect.pubKey, payload?.pubKey)
|
||||
assertEquals(dialect.kind, payload?.kind)
|
||||
assertEquals(dialect.content, payload?.content)
|
||||
assertEquals(dialect.sig, payload?.sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submitting does not make the submitter the author`() {
|
||||
val dialect = outsiderDialect()
|
||||
|
||||
val submission = roundTrip(dialect)
|
||||
|
||||
assertEquals(submitter, submission.pubKey)
|
||||
assertEquals(outsider, submission.payload()?.pubKey)
|
||||
assertNotEquals(submission.pubKey, submission.payload()?.pubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the envelope names what it carries without being opened`() {
|
||||
val dialect = outsiderDialect()
|
||||
|
||||
val submission = roundTrip(dialect)
|
||||
|
||||
assertEquals(DialectEvent.KIND, submission.payloadKind())
|
||||
assertEquals(dialect.id, submission.payloadId())
|
||||
assertEquals(outsider, submission.payloadAuthor())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a payload the group cannot read is null rather than empty`() {
|
||||
val submission = SubmissionEvent(
|
||||
id = "d".repeat(64),
|
||||
pubKey = submitter,
|
||||
createdAt = 1_700_000_100L,
|
||||
tags = arrayOf(),
|
||||
content = "not an event",
|
||||
sig = "",
|
||||
)
|
||||
|
||||
assertNull(submission.payload())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user