feat: sign an artifact's first version with it, not derive it after
A chapter attaches to a version rather than to an artifact, so the first version is the parent of everything a group later translates. It was not signed. Every device rebuilt it from the artifact on arrival, which put a row on disk naming the group as its author and carrying no signature to show for it -- a parent vouched for by its own signed children rather than the other way round. The reason was written into both ends: a first version proposed on its own would cost a second quorum for one form. That is an argument against a second session, and it stopped being an argument at all once a batch existed. `proposeSigningBatch` is one ceremony, one approval and one transcript whatever k is. The same reasoning was already overturned once, for the same shape. A chapter's chunks were briefly derived from the signed chapter's text for exactly this reason, and they carry their own signatures now. The artifact version is the case that was left behind, and it needs the same form: `ArtifactVersionEvent` names the artifact it is of, and that id is a hash over the group's key at the room's path, so it cannot be known until the proposal is authored. `initialVersionOf` takes the lead the session built, mirroring `ChunkEvent.splitOf`, and the artifact is item 0 because a version row whose artifact does not exist yet is a foreign key violation. Two things had to move with it, and both would have been silent. `ChatMessage.applyInnerEvent` no longer derives a version under an artifact. The derived row and the signed one hash differently -- different author, different timestamp -- so keeping both would have stood two versions against one artifact and let a chapter hang off whichever it found. The `ArtifactVersionEvent` arm no longer writes a chat line. It never used to reach one: a derived version wrote nothing. Signed, it would have put "Added 1.0 to artifact versions" under every "Added In Detention to artifacts", which is the noise the chunk arm already declines to make beside a chapter. An artifact signed before this keeps a version label nothing turns into a row, so its version does not appear. That is what the chapter's chunks cost too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -779,22 +779,12 @@ data class ChatMessage(
|
||||
)
|
||||
)
|
||||
|
||||
// An artifact arrives with the version it starts life
|
||||
// with, derived here rather than sent, so that every
|
||||
// device holding the artifact holds the same first
|
||||
// version. Nothing else can hang off an artifact until
|
||||
// one exists -- a chapter attaches to a version, not to
|
||||
// an artifact -- so an artifact without one is inert.
|
||||
MantraArtifactVersion.initialVersionOf(
|
||||
artifactEvent = artifactEvent,
|
||||
chatRoomId = groupId,
|
||||
)?.let { initialVersion ->
|
||||
database.mantraArtifactVersionDao().upsert(
|
||||
initialVersion.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
}
|
||||
// The version the artifact starts life with is its own
|
||||
// event, signed in the same batch and applied after this
|
||||
// one -- see AddArtifactViewModel.addArtifact. It used to
|
||||
// be derived here instead; deriving it now as well would
|
||||
// stand a second, unsigned version row against the same
|
||||
// artifact, since the two hash differently.
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
@@ -827,18 +817,15 @@ data class ChatMessage(
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "artifactVersion",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
|
||||
)
|
||||
// No line of its own. A version arrives as the second
|
||||
// item of the batch that carries its artifact, and the
|
||||
// artifact has already said so -- the same reason a
|
||||
// chunk writes no line beside its chapter. While the
|
||||
// version was derived here rather than signed it wrote
|
||||
// none either, so this is the transcript standing still
|
||||
// rather than losing something.
|
||||
}
|
||||
null
|
||||
}
|
||||
ChapterEvent.KIND -> {
|
||||
// The chunks the chapter splits into are their own events,
|
||||
|
||||
@@ -10,10 +10,8 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
|
||||
import press.mantra.compose.database.model.traits.TimestampedEntity
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -84,42 +82,6 @@ data class MantraArtifactVersion(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The version an artifact starts life with, derived from the artifact.
|
||||
*
|
||||
* The group signs an artifact; it does not sign this. So the first
|
||||
* version cannot be an event proposed on its own -- that would cost a
|
||||
* second quorum for one form -- and it cannot be invented by whichever
|
||||
* device notices the artifact first, because an invented id differs on
|
||||
* every device holding the same artifact and none of them would agree
|
||||
* about which version a chapter hangs off. Deriving it from the signed
|
||||
* artifact's own fields gives every device the same row from the same
|
||||
* bytes, which is the only property that matters here.
|
||||
*
|
||||
* It is a rumor -- empty signature -- because nobody signed it. What the
|
||||
* group signed is the artifact that declares it.
|
||||
*
|
||||
* Null when the artifact declares no version, which is every artifact
|
||||
* written before it did.
|
||||
*/
|
||||
fun initialVersionOf(
|
||||
artifactEvent: ArtifactEvent,
|
||||
chatRoomId: HexKey,
|
||||
): MantraArtifactVersion? {
|
||||
val versionLabel = artifactEvent.versionLabel() ?: return null
|
||||
|
||||
return fromArtifactVersionEventTemplate(
|
||||
artifactVersionEventTemplate = ArtifactVersionEvent.build(
|
||||
content = versionLabel,
|
||||
createdAt = artifactEvent.createdAt,
|
||||
) {
|
||||
addUnique(ArtifactIdTag.assemble(artifactEvent.id))
|
||||
},
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = artifactEvent.pubKey,
|
||||
)
|
||||
}
|
||||
|
||||
fun fromArtifactVersionEventTemplate(
|
||||
artifactVersionEventTemplate: EventTemplate<ArtifactVersionEvent>,
|
||||
chatRoomId: HexKey,
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
@@ -32,6 +33,47 @@ class ArtifactVersionEvent(
|
||||
const val KIND = 30301
|
||||
const val ALT_DESCRIPTION = "ArtifactVersion"
|
||||
|
||||
/**
|
||||
* The first version [artifact] declares, ready to be signed with it.
|
||||
*
|
||||
* An artifact and the version it starts life with go to the group as one
|
||||
* batch, so this is built from the artifact *after* it has been authored
|
||||
* under the group's key -- [artifact] is the unsigned event the session
|
||||
* will sign, which is where the id this carries comes from. Building it
|
||||
* anywhere else would mean naming an artifact id before one exists.
|
||||
*
|
||||
* It takes the artifact's own timestamp, so the batch reads as one act
|
||||
* rather than two events that happen to share a session.
|
||||
*
|
||||
* This used to be derived on arrival instead, from the label the
|
||||
* artifact carries -- the same shape a chapter's chunks were in, and
|
||||
* abandoned for the same reason. Deriving cost nothing while the
|
||||
* alternative was a second quorum, and nothing is what it bought: a row
|
||||
* naming the group as its author with no signature to show for it, which
|
||||
* a chapter then hangs off. A batch is one quorum, so the version can
|
||||
* carry the group's signature over its own label.
|
||||
*
|
||||
* Empty when the artifact declares no version -- every artifact written
|
||||
* before it did -- which leaves a batch of one and an artifact with no
|
||||
* version, exactly as before.
|
||||
*/
|
||||
fun initialVersionOf(artifact: Event): List<EventTemplate<ArtifactVersionEvent>> {
|
||||
val versionLabel = ArtifactEvent(
|
||||
id = artifact.id,
|
||||
pubKey = artifact.pubKey,
|
||||
createdAt = artifact.createdAt,
|
||||
tags = artifact.tags,
|
||||
content = artifact.content,
|
||||
sig = artifact.sig
|
||||
).versionLabel() ?: return emptyList()
|
||||
|
||||
return listOf(
|
||||
build(content = versionLabel, createdAt = artifact.createdAt) {
|
||||
addUnique(ArtifactIdTag.assemble(artifact.id))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun build(
|
||||
content: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
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.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
@@ -52,6 +53,15 @@ object ProposedEvent {
|
||||
}
|
||||
)
|
||||
|
||||
// Signed alongside the artifact it belongs to rather than on its own,
|
||||
// so this is almost always read as the second line of a batch of two.
|
||||
// Named after the artifact rather than the label, because the label is
|
||||
// the whole of the content and would otherwise be said twice.
|
||||
ArtifactVersionEvent.KIND -> Summary(
|
||||
label = "Version of the artifact",
|
||||
detail = event.content.ifBlank { "Unlabelled" }
|
||||
)
|
||||
|
||||
ChapterEvent.KIND -> Summary(
|
||||
label = "New chapter",
|
||||
detail = ChapterEvent(
|
||||
|
||||
@@ -20,6 +20,7 @@ import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.view.state.AddArtifactUIState
|
||||
@@ -75,6 +76,10 @@ class AddArtifactViewModel(
|
||||
* putting this in front of the group" and the group's only recourse
|
||||
* afterwards is social. A signature is the group saying it, and it takes a
|
||||
* quorum to say. A library is the group's, so the second is the honest one.
|
||||
*
|
||||
* Two events, one session: the artifact and the version it starts life with,
|
||||
* which a chapter later hangs off. All-or-nothing, which is right here --
|
||||
* an artifact with no version is inert, since nothing can attach to it.
|
||||
*/
|
||||
fun addArtifact(
|
||||
localChatRoom: LocalChatRoom,
|
||||
@@ -103,10 +108,12 @@ class AddArtifactViewModel(
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// The version label rides on the artifact rather than following it as
|
||||
// a second event. The group signs the artifact; a first version
|
||||
// proposed on its own would cost a second quorum for one form, and
|
||||
// every device derives the same first version from what was signed.
|
||||
// The label rides on the artifact and the version is signed beside
|
||||
// it, in the same batch. A first version proposed on its own would
|
||||
// cost a second quorum for one form, which is why it used to be
|
||||
// derived on arrival instead; a batch costs one quorum, so the row a
|
||||
// chapter hangs off can carry the group's signature rather than
|
||||
// being rebuilt from the artifact by every device that holds it.
|
||||
val artifactEventTemplate = ArtifactEvent.build(
|
||||
name = name,
|
||||
url = url,
|
||||
@@ -117,12 +124,15 @@ class AddArtifactViewModel(
|
||||
)
|
||||
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
frostSigningRepository.proposeSigningBatch(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = artifactEventTemplate.kind,
|
||||
tags = artifactEventTemplate.tags,
|
||||
content = artifactEventTemplate.content,
|
||||
lead = artifactEventTemplate,
|
||||
// The version names the artifact it is of, and that id is a
|
||||
// hash over the group's key at the room's path -- neither of
|
||||
// which this screen knows or should. The artifact comes back
|
||||
// built, and the version is read off the label it declares.
|
||||
dependents = ArtifactVersionEvent::initialVersionOf
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose an artifact for signing", error)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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