Merge branch 'mantra' into claude/happy-gauss-dbe258
This commit is contained in:
commit
54091099a9
18 files changed
+858
-377
No files matched your search
-144
@@ -1,144 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
+59
-9
@@ -23,6 +23,7 @@ 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
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
|
||||
/**
|
||||
* An artifact the group signed, from proposal to rows, against real FROST.
|
||||
@@ -227,21 +228,70 @@ class SignedArtifactTest {
|
||||
assertEquals(dialectId, artifact?.dialectId)
|
||||
}
|
||||
|
||||
/** The second item of the batch, through the same quorum as the first. */
|
||||
private fun signedInitialVersionEvent(versionLabel: String = "1.0"): ArtifactVersionEvent {
|
||||
// Built from the unsigned lead, which is what `proposeSigningBatch` hands
|
||||
// to `dependents` -- the artifact's id is settled at proposal, not at
|
||||
// signature, which is the only reason a version can name it at all.
|
||||
val lead = unsignedEventOf(proposalTemplate(versionLabel))
|
||||
val item = itemOver(unsignedEventOf(ArtifactVersionEvent.initialVersionOf(lead).single()))
|
||||
val signed = FrostSigningManager.signedEvent(item, groupSignature(item))
|
||||
|
||||
return ArtifactVersionEvent(
|
||||
signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig
|
||||
)
|
||||
}
|
||||
|
||||
@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)
|
||||
// A chapter attaches to a version rather than to an artifact, so an
|
||||
// artifact that arrives without one is inert. Both come out of one
|
||||
// batch, so neither can arrive without the other.
|
||||
val artifact = MantraArtifact.fromArtifactEvent(
|
||||
signedArtifactEvent(versionLabel = "First Edition"), chatRoomId
|
||||
)
|
||||
val version = MantraArtifactVersion.fromArtifactVersionEvent(
|
||||
signedInitialVersionEvent(versionLabel = "First Edition"), 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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first version carries the group's signature over its own id`() {
|
||||
// The point of signing it rather than deriving it. A derived version was
|
||||
// authored by the group and signed by nobody -- indistinguishable, on the
|
||||
// row, from one somebody made up, and the parent of every chapter in it.
|
||||
val version = MantraArtifactVersion.fromArtifactVersionEvent(
|
||||
signedInitialVersionEvent(), chatRoomId
|
||||
)
|
||||
|
||||
assertNotNull(version)
|
||||
assertNotEquals("", version.signature)
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = version.signature.hexToByteArray(),
|
||||
hash = version.id.hexToByteArray(),
|
||||
pubKey = version.publicKey.hexToByteArray()
|
||||
),
|
||||
"a first version should carry a signature the group's key made over its own id"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the version is a second message, signed under its own nonce`() {
|
||||
// Two items of a batch are two independent FROST instances: k signatures
|
||||
// over k ids, never one signature stretched over both. A version whose
|
||||
// signature verified against the artifact's id would mean a nonce had
|
||||
// been reused, which is how a share is extracted rather than a cosmetic
|
||||
// mix-up.
|
||||
val artifact = signedArtifactEvent()
|
||||
val version = signedInitialVersionEvent()
|
||||
|
||||
assertNotEquals(artifact.id, version.id)
|
||||
assertNotEquals(artifact.sig, version.sig)
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
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
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
|
||||
/**
|
||||
* The version an artifact starts life with, built to be signed beside it.
|
||||
*
|
||||
* It used to be derived on arrival from the label the artifact carries, because
|
||||
* proposing it on its own would have cost a second quorum for one form. A batch
|
||||
* costs one quorum, so it is signed now -- and a chapter hangs off this row,
|
||||
* which is the reason to care: a version nobody signed is a parent vouched for
|
||||
* by its own signed children rather than the other way round.
|
||||
*
|
||||
* What has to hold is that every input comes off the artifact. The id is not
|
||||
* settled here -- the signing session hashes it under the group's key -- so an
|
||||
* input read from anywhere else would not show up as a wrong row. It would show
|
||||
* up as two devices proposing two different batches, which reads as a batch that
|
||||
* simply never aggregates.
|
||||
*/
|
||||
class InitialArtifactVersionTest {
|
||||
private val groupKey = "a".repeat(64)
|
||||
private val dialectId = "b".repeat(64)
|
||||
|
||||
/**
|
||||
* The artifact as the session will sign it: the proposer's fields re-authored
|
||||
* under the group's key. This is what `proposeSigningBatch` hands to
|
||||
* `dependents`, so it is what the version has to be built from.
|
||||
*/
|
||||
private fun leadArtifact(
|
||||
versionLabel: String = "1.0",
|
||||
createdAt: Long = 1_700_000_000,
|
||||
): Event {
|
||||
val template = ArtifactEvent.build(
|
||||
name = "In Detention",
|
||||
url = "example.com",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = dialectId,
|
||||
versionLabel = versionLabel,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
return Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content
|
||||
),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = ""
|
||||
)
|
||||
}
|
||||
|
||||
/** The version item as the session would author it, so the accessors read. */
|
||||
private fun versionOf(artifact: Event): ArtifactVersionEvent? =
|
||||
ArtifactVersionEvent.initialVersionOf(artifact).singleOrNull()?.let { template ->
|
||||
ArtifactVersionEvent(
|
||||
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 = ""
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the version names the artifact it was built from`() {
|
||||
// The whole reason this takes the lead event rather than a form's fields:
|
||||
// the id it carries is a hash over the group's key at the room's path, so
|
||||
// there is no artifact to name until the proposal has been authored.
|
||||
val artifact = leadArtifact()
|
||||
|
||||
assertEquals(artifact.id, versionOf(artifact)?.artifactId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it carries the label the artifact declared`() {
|
||||
val artifact = leadArtifact(versionLabel = "First Edition")
|
||||
|
||||
assertEquals("First Edition", versionOf(artifact)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it takes the artifact's own timestamp rather than the clock`() {
|
||||
// So the batch reads as one act. Reading the clock here would agree with
|
||||
// itself on one device and disagree between two that proposed the same
|
||||
// artifact a second apart -- which is a batch nobody can aggregate, and
|
||||
// the case nobody can reproduce on demand.
|
||||
val artifact = leadArtifact(createdAt = 1_700_000_042)
|
||||
|
||||
assertEquals(1_700_000_042, versionOf(artifact)?.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact declares exactly one first version`() {
|
||||
// Item 0 is the artifact and item 1 is this, and a chapter hangs off
|
||||
// whichever version it finds. Two would make that a coin toss.
|
||||
assertEquals(1, ArtifactVersionEvent.initialVersionOf(leadArtifact()).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two devices build the same version from the same artifact`() {
|
||||
// Every signer authors the batch itself and signs the ids it arrives at.
|
||||
// Devices that disagree here produce partial signatures over different
|
||||
// messages, and the session stalls rather than saying why.
|
||||
val artifact = leadArtifact()
|
||||
|
||||
assertEquals(versionOf(artifact)?.id, versionOf(artifact)?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `artifacts alike but for the label do not share a version`() {
|
||||
// The label is bound into the id rather than hung beside it, so renaming
|
||||
// a version cannot leave the row it replaces standing.
|
||||
val first = versionOf(leadArtifact(versionLabel = "1.0"))
|
||||
val second = versionOf(leadArtifact(versionLabel = "2.0"))
|
||||
|
||||
assertNotEquals(first?.id, second?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact that declares no version proposes none`() {
|
||||
// Artifacts written before the artifact carried its first version. The
|
||||
// batch is then one item, which is what an artifact proposal used to be.
|
||||
val declared = leadArtifact()
|
||||
val silent = Event(
|
||||
id = declared.id,
|
||||
pubKey = declared.pubKey,
|
||||
createdAt = declared.createdAt,
|
||||
kind = declared.kind,
|
||||
tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
|
||||
.toTypedArray(),
|
||||
content = declared.content,
|
||||
sig = declared.sig
|
||||
)
|
||||
|
||||
assertTrue(ArtifactVersionEvent.initialVersionOf(silent).isEmpty())
|
||||
assertNull(versionOf(silent))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user