mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4199 from vitorpamplona/claude/fix-event-tagging-dtag
DRAFT — hold: d-tag collision for addressable tagging targets is a Tapestry spec defect
This commit is contained in:
+61
-5
@@ -32,29 +32,80 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Po
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
/** What an event tagging is about: an addressable event (`a`) or a plain one (`e`). */
|
||||
@Immutable
|
||||
sealed interface TaggingTarget {
|
||||
/** The first 8 characters used in the assertion `d`. */
|
||||
/**
|
||||
* What stands for this target in the assertion `d`: `<id8>` for a plain event,
|
||||
* `<author8>-<d16>-<hash8>` for an addressable one. Kind 39999 is addressable, so two
|
||||
* taggings that share a `d` do not sit side by side — the later one replaces the earlier.
|
||||
*
|
||||
* The draft fixes these widths, so the separation is 32 bits of hash rather than a guarantee:
|
||||
* it removes the old rule's *certain* collision between any two of an author's events, and
|
||||
* leaves a birthday-bound residue that the draft accepts. Widening it is an upstream change,
|
||||
* not ours.
|
||||
*/
|
||||
val prefix: String
|
||||
|
||||
@Immutable
|
||||
data class ByAddress(
|
||||
val address: Address,
|
||||
) : TaggingTarget {
|
||||
// the author segment of the coordinate, not its kind
|
||||
override val prefix get() = address.pubKeyHex.take(8)
|
||||
/**
|
||||
* `<author8>-<d16>-<hash8>`. Only `hash8` carries uniqueness — it is taken over the WHOLE
|
||||
* coordinate, because no single segment identifies the target: the author repeats across
|
||||
* everything they write, and the kind across everything of a type. The other two are
|
||||
* decoration the draft asks for so a `d` stays readable.
|
||||
*
|
||||
* An empty `dTag` yields an empty `d16`, i.e. `<author8>--<hash8>`, which the draft spells
|
||||
* out. A plain event uses its own id (below): that already covers the whole event.
|
||||
*/
|
||||
override val prefix get() = "${address.pubKeyHex.take(AUTHOR_CHARS)}-${d16()}-${hash8()}"
|
||||
|
||||
/**
|
||||
* The draft counts `d16` in UTF-16 code units, which can cut an astral character in half
|
||||
* and leave a dangling high surrogate. That is not survivable: the id is hashed from the
|
||||
* intact char, but UTF-8 encoding the event for the wire has no encoding for half a
|
||||
* character and substitutes `?`, so the relay re-hashes different bytes, gets a different
|
||||
* id, and rejects the event. Verified end to end — see the test.
|
||||
*
|
||||
* So the half character is dropped, giving 15 units instead of 16. `d16` is decoration
|
||||
* the draft says carries no uniqueness, and dropping it stays deterministic, so nothing
|
||||
* that matters is lost; `hash8` still separates the targets.
|
||||
*/
|
||||
private fun d16(): String {
|
||||
val cut = address.dTag.take(D_TAG_CHARS)
|
||||
return if (cut.lastOrNull()?.isHighSurrogate() == true) cut.dropLast(1) else cut
|
||||
}
|
||||
|
||||
// Lowercase hex of the UTF-8 coordinate exactly as the `a` tag carries it: no trimming,
|
||||
// no normalization, no reordering. Hex.encode already emits lower case.
|
||||
private fun hash8() = sha256(address.toValue().encodeToByteArray()).toHexKey().take(HASH_CHARS)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ByEventId(
|
||||
val eventId: HexKey,
|
||||
) : TaggingTarget {
|
||||
override val prefix get() = eventId.take(8)
|
||||
override val prefix get() = eventId.take(EVENT_ID_CHARS)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EVENT_ID_CHARS = 8
|
||||
const val AUTHOR_CHARS = 8
|
||||
const val HASH_CHARS = 8
|
||||
|
||||
/**
|
||||
* UTF-16 code units, which is what the draft pins ("as JavaScript `String.prototype.slice`
|
||||
* counts them") and what [String.take] counts.
|
||||
*/
|
||||
const val D_TAG_CHARS = 16
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +156,8 @@ object TaggingHeader {
|
||||
* [TaggingHeader]:
|
||||
*
|
||||
* ```
|
||||
* ["d", "event-tag-<tagSlug>-<target8>-<asserter8>"]
|
||||
* ["d", "event-tag-<tagSlug>-<target8>-<asserter8>"] // <target8> = <id8>, or
|
||||
* // <author8>-<d16>-<hash8> for an `a`
|
||||
* ["a", <target coordinate>] or ["e", <target id>]
|
||||
* ["z", <nostr-event-tag concept>]
|
||||
* ["z", <tagging header coordinate>]
|
||||
@@ -113,6 +165,10 @@ object TaggingHeader {
|
||||
* ```
|
||||
*
|
||||
* An `a`/`e` reference without such a `z` is not a tagging and must not be read as one.
|
||||
*
|
||||
* The `d` is a replaceability key, never a source of truth: the draft forbids parsing it back into
|
||||
* its fields, because `d16` is user-influenced text that may itself contain hyphens. [parse] reads
|
||||
* the target from the `a`/`e` tag, which is authoritative.
|
||||
*/
|
||||
@Immutable
|
||||
data class EventTagging(
|
||||
|
||||
+106
@@ -36,6 +36,7 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotEquals
|
||||
|
||||
/** Regressions found in the audit: each failed before its fix. */
|
||||
class AuditRegressionTest {
|
||||
@@ -111,6 +112,111 @@ class AuditRegressionTest {
|
||||
assertFalse(name in node.linkedAddressIds())
|
||||
}
|
||||
|
||||
// The `d` must separate two addressable targets. kind 39999 is addressable, so a colliding
|
||||
// `d` means the second tagging silently replaces the first.
|
||||
@Test
|
||||
fun twoAddressableTargetsByTheSameAuthorGetDifferentDTags() {
|
||||
val first = TaggingTarget.ByAddress(Address(39999, bob, "good-tag"))
|
||||
val second = TaggingTarget.ByAddress(Address(39999, bob, "other-tag"))
|
||||
|
||||
assertNotEquals(
|
||||
EventTagging.dTag("awesome-tag", first, alice),
|
||||
EventTagging.dTag("awesome-tag", second, alice),
|
||||
)
|
||||
}
|
||||
|
||||
// Same idea across the other two coordinate segments: only one of the three may differ.
|
||||
@Test
|
||||
fun addressableTargetsDifferingOnlyByKindGetDifferentDTags() {
|
||||
assertNotEquals(
|
||||
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "tag")), alice),
|
||||
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(30023, bob, "tag")), alice),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addressableTargetsDifferingOnlyByAuthorGetDifferentDTags() {
|
||||
assertNotEquals(
|
||||
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "tag")), alice),
|
||||
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, other, "tag")), alice),
|
||||
)
|
||||
}
|
||||
|
||||
// ...while the `d` stays deterministic, which is what lets a re-tag replace its own assertion
|
||||
// instead of piling up a second one. `event-taggings.md` § "The assertion d-tag (normative)":
|
||||
// `<author8>-<d16>-<hash8>`, only hash8 unique. The hashes are SHA-256 prefixes of the full
|
||||
// coordinate computed outside this codebase, so the test cross-checks the derivation rather
|
||||
// than restating it: a change of hash input would silently orphan every assertion signed.
|
||||
@Test
|
||||
fun theAddressablePrefixIsAuthorThenDTagThenTheHashOfTheWholeCoordinate() {
|
||||
assertEquals(
|
||||
"event-tag-awesome-tag-bbbbbbbb-good-tag-6a5e1c40-aaaaaaaa",
|
||||
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "good-tag")), alice),
|
||||
)
|
||||
assertEquals(
|
||||
"event-tag-awesome-tag-bbbbbbbb-other-tag-e89b797c-aaaaaaaa",
|
||||
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "other-tag")), alice),
|
||||
)
|
||||
// same author8 AND same d16 as the first; only the kind differs, so only hash8 separates them
|
||||
assertEquals(
|
||||
"event-tag-awesome-tag-bbbbbbbb-good-tag-96856fd8-aaaaaaaa",
|
||||
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(30023, bob, "good-tag")), alice),
|
||||
)
|
||||
}
|
||||
|
||||
// The draft spells this case out: an empty `d` yields an empty `d16`, hence the double hyphen.
|
||||
@Test
|
||||
fun anEmptyDTagSegmentLeavesADoubleHyphen() {
|
||||
assertEquals(
|
||||
"event-tag-awesome-tag-bbbbbbbb--76600e1a-aaaaaaaa",
|
||||
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "")), alice),
|
||||
)
|
||||
}
|
||||
|
||||
// d16 is the first 16 UTF-16 code units, verbatim and truncated — decoration, not identity.
|
||||
@Test
|
||||
fun aLongDTagIsTruncatedToSixteenCharactersInTheDecoration() {
|
||||
val d = "a-very-long-d-tag-that-exceeds-sixteen"
|
||||
assertEquals(
|
||||
"event-tag-awesome-tag-bbbbbbbb-a-very-long-d-ta-58899d62-aaaaaaaa",
|
||||
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, d)), alice),
|
||||
)
|
||||
}
|
||||
|
||||
// Truncating at 16 UTF-16 code units can cut an astral character in half. The id is hashed
|
||||
// from the intact char while the wire bytes get `?` in its place, so the relay re-hashes
|
||||
// different bytes and rejects the event. The half character is dropped instead.
|
||||
@Test
|
||||
fun aDTagCutMidAstralCharacterDoesNotLeaveHalfACharacterInTheD() {
|
||||
// 15 ASCII then an emoji: the 16th code unit is the high surrogate of the pair.
|
||||
val dTag = "a".repeat(15) + "\uD83D\uDE00" + "tail"
|
||||
val d = EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, dTag)), alice)
|
||||
|
||||
assertFalse(d.any { it.isHighSurrogate() || it.isLowSurrogate() }, "the `d` still carries half a character: $d")
|
||||
// 15 units of decoration, not 16, and hash8 is over the untruncated coordinate.
|
||||
assertEquals("event-tag-awesome-tag-bbbbbbbb-${"a".repeat(15)}-15c09d87-aaaaaaaa", d)
|
||||
}
|
||||
|
||||
// The property the above protects: what is hashed for the id must survive UTF-8 encoding,
|
||||
// or the relay recomputes a different id. Pins the bytes, not just the string.
|
||||
@Test
|
||||
fun theDSurvivesAUtf8RoundTripByteForByte() {
|
||||
val dTag = "a".repeat(15) + "\uD83D\uDE00" + "tail"
|
||||
val d = EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, dTag)), alice)
|
||||
|
||||
assertEquals(d, d.encodeToByteArray().decodeToString(), "the `d` does not survive UTF-8 encoding")
|
||||
}
|
||||
|
||||
// A plain event is still named by its own id: it already covers the whole event, and hashing
|
||||
// it would only cost a round of SHA-256 per assertion built. The `e` branch is unchanged.
|
||||
@Test
|
||||
fun plainEventTargetsStillUseTheirOwnId() {
|
||||
assertEquals(
|
||||
"event-tag-awesome-tag-11111111-aaaaaaaa",
|
||||
EventTagging.dTag("awesome-tag", TaggingTarget.ByEventId("1".repeat(64)), alice),
|
||||
)
|
||||
}
|
||||
|
||||
// Authored JSON: an explicit null in a list field must not make the whole section unreadable.
|
||||
@Test
|
||||
fun explicitNullListsInTheWordSectionAreTolerated() {
|
||||
|
||||
+4
-1
@@ -247,7 +247,10 @@ class TapestryExtensionsTest {
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
listOf("d", "event-tag-awesome-tag-cccccccc-aaaaaaaa"),
|
||||
// <author8>-<d16>-<hash8>: cccccccc is the assistant, good-tag the target's own
|
||||
// `d`, and 4d7a80b1 = sha256("39999:${"c".repeat(64)}:good-tag").take(8), the one
|
||||
// segment that makes two of the assistant's tags land on different addresses.
|
||||
listOf("d", "event-tag-awesome-tag-cccccccc-good-tag-4d7a80b1-aaaaaaaa"),
|
||||
listOf("z", "39998:$ta:nostr-event-tag"),
|
||||
listOf("z", "39999:$bob:tagging:awesome-tag-tagging"),
|
||||
listOf("a", "39999:$assistant:good-tag"),
|
||||
|
||||
Reference in New Issue
Block a user