diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt index 1e157b23e6..69d42d9956 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt @@ -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`: `` for a plain event, + * `--` 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) + /** + * `--`. 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. `--`, 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---"] + * ["d", "event-tag---"] // = , or + * // -- for an `a` * ["a", ] or ["e", ] * ["z", ] * ["z", ] @@ -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( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt index ab0579b6df..08b44cca86 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt @@ -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)": + // `--`, 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() { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt index 8e4b8a72a6..f244c1da1d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt @@ -247,7 +247,10 @@ class TapestryExtensionsTest { assertEquals( listOf( - listOf("d", "event-tag-awesome-tag-cccccccc-aaaaaaaa"), + // --: 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"),