From 0c2614fbf5a36af8c7708a082bc3211a09ac2255 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 30 Apr 2026 11:21:23 +0200 Subject: [PATCH] fix(nip39): reject identity claim tags without a platform separator Malformed `i` tags whose platform-identity field has no `:` (observed in production logcat as e.g. `["i", " ", " "]`) made `create()` throw `IndexOutOfBoundsException` from destructuring `split(':')`. The exception was caught but each event spammed multiple stack traces. Validate the separator up front in `parse()` and return null silently. Also fix the diagnostic `joinToString { "," }` typo so the log shows real tag contents. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../nip39ExtIdentities/IdentityClaimTag.kt | 3 +- .../IdentityClaimTagTest.kt | 158 ++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTagTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTag.kt index 61adf9a0b..081572a70 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTag.kt @@ -60,10 +60,11 @@ abstract class IdentityClaimTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } ensure(tag[2].isNotEmpty()) { return null } + ensure(':' in tag[1]) { return null } return try { create(tag[1], tag[2]) } catch (e: Exception) { - Log.e("IdentityClaim", "Can't parse identity [${tag.joinToString { "," }}]", e) + Log.e("IdentityClaim", "Can't parse identity [${tag.joinToString(", ")}]", e) null } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTagTest.kt new file mode 100644 index 000000000..3f5df3681 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTagTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip39ExtIdentities + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class IdentityClaimTagTest { + @Test + fun parseGitHubIdentity() { + val parsed = IdentityClaimTag.parse(arrayOf("i", "github:alice", "proof123")) + + assertNotNull(parsed) + assertIs(parsed) + assertEquals("alice", parsed.identity) + assertEquals("proof123", parsed.proof) + } + + @Test + fun parseTwitterIdentity() { + val parsed = IdentityClaimTag.parse(arrayOf("i", "twitter:bob", "1234567890")) + + assertNotNull(parsed) + assertIs(parsed) + assertEquals("bob", parsed.identity) + assertEquals("1234567890", parsed.proof) + } + + @Test + fun parseTelegramIdentity() { + val parsed = IdentityClaimTag.parse(arrayOf("i", "telegram:carol", "proof")) + + assertNotNull(parsed) + assertIs(parsed) + assertEquals("carol", parsed.identity) + } + + @Test + fun parseMastodonIdentity() { + val parsed = IdentityClaimTag.parse(arrayOf("i", "mastodon:dave@example.social", "proof")) + + assertNotNull(parsed) + assertIs(parsed) + assertEquals("dave@example.social", parsed.identity) + } + + @Test + fun parseUnknownPlatformReturnsUnsupportedIdentity() { + val parsed = IdentityClaimTag.parse(arrayOf("i", "matrix:erin", "proof")) + + assertNotNull(parsed) + assertIs(parsed) + assertEquals("matrix", parsed.platform) + assertEquals("erin", parsed.identity) + assertEquals("proof", parsed.proof) + } + + @Test + fun parsePlatformIsCaseInsensitive() { + val parsed = IdentityClaimTag.parse(arrayOf("i", "GitHub:frank", "proof")) + + assertNotNull(parsed) + assertIs(parsed) + assertEquals("frank", parsed.identity) + } + + @Test + fun parseReturnsNullForShortTags() { + assertNull(IdentityClaimTag.parse(arrayOf())) + assertNull(IdentityClaimTag.parse(arrayOf("i"))) + assertNull(IdentityClaimTag.parse(arrayOf("i", "github:alice"))) + } + + @Test + fun parseReturnsNullForWrongTagName() { + assertNull(IdentityClaimTag.parse(arrayOf("e", "github:alice", "proof"))) + assertNull(IdentityClaimTag.parse(arrayOf("p", "github:alice", "proof"))) + assertNull(IdentityClaimTag.parse(arrayOf("", "github:alice", "proof"))) + } + + @Test + fun parseReturnsNullForEmptyPlatformIdentity() { + assertNull(IdentityClaimTag.parse(arrayOf("i", "", "proof"))) + } + + @Test + fun parseReturnsNullForEmptyProof() { + assertNull(IdentityClaimTag.parse(arrayOf("i", "github:alice", ""))) + } + + /** + * Regression: malformed `i` tags whose platform-identity field has no `:` + * (observed in production logcat as e.g. `["i", " ", " "]`) used to make + * `create()` throw `IndexOutOfBoundsException` from destructuring `split(':')`. + * `parse()` caught the exception but logged a noisy stack trace per offending event. + * Now `parse()` rejects such tags up front and returns null silently. + */ + @Test + fun parseReturnsNullWhenPlatformIdentityHasNoColon() { + assertNull(IdentityClaimTag.parse(arrayOf("i", "githubalice", "proof"))) + assertNull(IdentityClaimTag.parse(arrayOf("i", " ", " "))) + assertNull(IdentityClaimTag.parse(arrayOf("i", "no-colon-here", "proof"))) + } + + @Test + fun parseExtraTagFieldsAreIgnored() { + val parsed = IdentityClaimTag.parse(arrayOf("i", "github:alice", "proof", "extra", "fields")) + + assertNotNull(parsed) + assertIs(parsed) + assertEquals("alice", parsed.identity) + assertEquals("proof", parsed.proof) + } + + @Test + fun roundTripGitHub() { + val original = GitHubIdentity("alice", "proof123") + val parsed = IdentityClaimTag.parse(original.toTagArray()) + + assertNotNull(parsed) + assertIs(parsed) + assertEquals(original.identity, parsed.identity) + assertEquals(original.proof, parsed.proof) + } + + @Test + fun roundTripUnsupportedPreservesPlatform() { + val original = UnsupportedIdentity("matrix", "erin", "proof") + val parsed = IdentityClaimTag.parse(original.toTagArray()) + + assertNotNull(parsed) + assertIs(parsed) + assertEquals("matrix", parsed.platform) + assertEquals("erin", parsed.identity) + assertEquals("proof", parsed.proof) + } +}