Skip to content

Commit 0b23ded

Browse files
committed
fix: validate pubky before add
1 parent 1e38929 commit 0b23ded

6 files changed

Lines changed: 197 additions & 13 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package to.bitkit.models
2+
3+
import java.util.Locale
4+
5+
object PubkyPublicKeyFormat {
6+
private const val pubkyPrefix = "pubky"
7+
private const val rawKeyLength = 52
8+
const val maximumInputLength = 57
9+
10+
private val zBase32Regex = Regex("^[ybndrfg8ejkmcpqxot1uwisza345h769]+$")
11+
12+
fun bounded(input: String): String {
13+
return input
14+
.trim()
15+
.lowercase(Locale.US)
16+
.take(maximumInputLength)
17+
}
18+
19+
fun normalized(input: String): String? {
20+
val normalizedInput = input.trim().lowercase(Locale.US)
21+
val rawKey = normalizedInput.removePrefix(pubkyPrefix)
22+
23+
if (rawKey.length != rawKeyLength || !zBase32Regex.matches(rawKey)) {
24+
return null
25+
}
26+
27+
return "$pubkyPrefix$rawKey"
28+
}
29+
30+
fun matches(lhs: String?, rhs: String?): Boolean {
31+
val normalizedLhs = lhs?.let(::normalized) ?: return false
32+
val normalizedRhs = rhs?.let(::normalized) ?: return false
33+
return normalizedLhs == normalizedRhs
34+
}
35+
}

app/src/main/java/to/bitkit/repositories/PubkyRepo.kt

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import to.bitkit.models.HomegateResponse
3131
import to.bitkit.models.PubkyProfile
3232
import to.bitkit.models.PubkyProfileData
3333
import to.bitkit.models.PubkyProfileLink
34+
import to.bitkit.models.PubkyPublicKeyFormat
3435
import to.bitkit.services.PubkyService
3536
import to.bitkit.utils.AppError
3637
import to.bitkit.utils.Logger
@@ -62,8 +63,6 @@ class PubkyRepo @Inject constructor(
6263
private const val PUBKY_SCHEME = "pubky://"
6364
private const val AVATAR_MAX_SIZE = 400
6465
private const val AVATAR_QUALITY = 80
65-
private const val PUBKY_KEY_LENGTH = 52
66-
private val Z_BASE_32_REGEX = Regex("^[ybndrfg8ejkmcpqxot1uwisza345h769]+$")
6766
}
6867

6968
private val scope = CoroutineScope(ioDispatcher + SupervisorJob())
@@ -767,11 +766,8 @@ class PubkyRepo @Inject constructor(
767766
}
768767

769768
private fun requireAddableContactPublicKey(publicKey: String): String {
770-
val prefixedKey = publicKey.trim().ensurePubkyPrefix()
771-
val strippedKey = prefixedKey.removePrefix(PUBKY_PREFIX)
772-
if (strippedKey.length != PUBKY_KEY_LENGTH || !Z_BASE_32_REGEX.matches(strippedKey)) {
773-
throw PubkyContactError.InvalidFormat
774-
}
769+
val prefixedKey = PubkyPublicKeyFormat.normalized(publicKey)
770+
?: throw PubkyContactError.InvalidFormat
775771
if (_publicKey.value == prefixedKey) {
776772
throw PubkyContactError.CannotAddSelf
777773
}

app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
1717
import androidx.compose.foundation.layout.padding
1818
import androidx.compose.foundation.layout.size
1919
import androidx.compose.foundation.shape.CircleShape
20+
import androidx.compose.foundation.text.KeyboardOptions
2021
import androidx.compose.material3.ExperimentalMaterial3Api
2122
import androidx.compose.material3.Icon
2223
import androidx.compose.material3.IconButton
@@ -41,6 +42,8 @@ import androidx.compose.ui.graphics.drawscope.rotate
4142
import androidx.compose.ui.platform.LocalContext
4243
import androidx.compose.ui.res.painterResource
4344
import androidx.compose.ui.res.stringResource
45+
import androidx.compose.ui.text.input.KeyboardCapitalization
46+
import androidx.compose.ui.text.input.KeyboardType
4447
import androidx.compose.ui.text.style.TextAlign
4548
import androidx.compose.ui.tooling.preview.Preview
4649
import androidx.compose.ui.unit.dp
@@ -51,6 +54,7 @@ import to.bitkit.ext.ellipsisMiddle
5154
import to.bitkit.ext.getClipboardText
5255
import to.bitkit.models.PubkyProfile
5356
import to.bitkit.models.PubkyProfileLink
57+
import to.bitkit.models.PubkyPublicKeyFormat
5458
import to.bitkit.ui.components.BodyM
5559
import to.bitkit.ui.components.BodyS
5660
import to.bitkit.ui.components.BottomSheet
@@ -72,39 +76,52 @@ import to.bitkit.ui.utils.withAccent
7276

7377
// region AddContactSheet (bottom sheet)
7478

75-
private const val PUBKY_INPUT_MAX_LENGTH = 64
76-
7779
@OptIn(ExperimentalMaterial3Api::class)
7880
@Composable
7981
fun AddContactSheet(
82+
currentPublicKey: String?,
8083
onDismiss: () -> Unit,
8184
onSubmit: (publicKey: String) -> Unit,
8285
onScanQr: () -> Unit,
8386
) {
8487
val context = LocalContext.current
8588
var publicKeyInput by remember { mutableStateOf("") }
89+
val trimmedInput = publicKeyInput.trim()
90+
val normalizedInput = PubkyPublicKeyFormat.normalized(trimmedInput)
91+
val validationMessage = when {
92+
trimmedInput.isEmpty() -> null
93+
PubkyPublicKeyFormat.matches(trimmedInput, currentPublicKey) ->
94+
context.getString(R.string.contacts__add_error_self)
95+
normalizedInput == null ->
96+
context.getString(R.string.contacts__add_error_invalid_key)
97+
else -> null
98+
}
8699

87100
BottomSheet(
88101
onDismissRequest = onDismiss,
89102
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
90103
) {
91104
AddContactSheetContent(
92105
publicKeyInput = publicKeyInput,
93-
onPublicKeyChange = { publicKeyInput = it.take(PUBKY_INPUT_MAX_LENGTH) },
106+
validationMessage = validationMessage,
107+
isSubmitEnabled = normalizedInput != null && validationMessage == null,
108+
onPublicKeyChange = { publicKeyInput = PubkyPublicKeyFormat.bounded(it) },
94109
onPaste = {
95110
context.getClipboardText()?.trim()?.let {
96-
publicKeyInput = it.take(PUBKY_INPUT_MAX_LENGTH)
111+
publicKeyInput = PubkyPublicKeyFormat.bounded(it)
97112
}
98113
},
99114
onScanQr = onScanQr,
100-
onSubmit = { onSubmit(publicKeyInput.trim()) },
115+
onSubmit = { normalizedInput?.let(onSubmit) },
101116
)
102117
}
103118
}
104119

105120
@Composable
106121
private fun AddContactSheetContent(
107122
publicKeyInput: String,
123+
validationMessage: String?,
124+
isSubmitEnabled: Boolean,
108125
onPublicKeyChange: (String) -> Unit,
109126
onPaste: () -> Unit,
110127
onScanQr: () -> Unit,
@@ -127,6 +144,17 @@ private fun AddContactSheetContent(
127144
onValueChange = onPublicKeyChange,
128145
placeholder = stringResource(R.string.contacts__add_pubky_placeholder),
129146
singleLine = true,
147+
isError = validationMessage != null,
148+
keyboardOptions = KeyboardOptions(
149+
capitalization = KeyboardCapitalization.None,
150+
autoCorrectEnabled = false,
151+
keyboardType = KeyboardType.Ascii,
152+
),
153+
supportingText = validationMessage?.let { message ->
154+
{
155+
BodyS(text = message, color = Colors.Red)
156+
}
157+
},
130158
trailingIcon = {
131159
IconButton(onClick = onPaste) {
132160
Icon(
@@ -152,7 +180,7 @@ private fun AddContactSheetContent(
152180
PrimaryButton(
153181
text = stringResource(R.string.contacts__add_button),
154182
onClick = onSubmit,
155-
enabled = publicKeyInput.isNotBlank(),
183+
enabled = isSubmitEnabled,
156184
modifier = Modifier.weight(1f)
157185
)
158186
}
@@ -440,6 +468,8 @@ private fun SheetPreview() {
440468
AppThemeSurface {
441469
AddContactSheetContent(
442470
publicKeyInput = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
471+
validationMessage = null,
472+
isSubmitEnabled = true,
443473
onPublicKeyChange = {},
444474
onPaste = {},
445475
onScanQr = {},

app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ private fun Content(
129129

130130
if (showAddContactSheet) {
131131
AddContactSheet(
132+
currentPublicKey = uiState.myProfile?.publicKey,
132133
onDismiss = { showAddContactSheet = false },
133134
onSubmit = { publicKey ->
134135
showAddContactSheet = false
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package to.bitkit.models
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertFalse
6+
import kotlin.test.assertNull
7+
import kotlin.test.assertTrue
8+
9+
class PubkyPublicKeyFormatTest {
10+
11+
@Test
12+
fun `bounded trims lowercases and caps input`() {
13+
val overlongInput =
14+
" PUBKYYBNDRFG8EJKMCPQXOT1UWISZA345H769YBNDRFG8EJKMCPQXOT1Uextra "
15+
16+
val bounded = PubkyPublicKeyFormat.bounded(overlongInput)
17+
18+
assertEquals(PubkyPublicKeyFormat.maximumInputLength, bounded.length)
19+
assertEquals("pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u", bounded)
20+
}
21+
22+
@Test
23+
fun `normalized accepts prefixed and unprefixed keys`() {
24+
val rawKey = "ybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u"
25+
val prefixedKey = "pubky$rawKey"
26+
27+
assertEquals(prefixedKey, PubkyPublicKeyFormat.normalized(rawKey))
28+
assertEquals(prefixedKey, PubkyPublicKeyFormat.normalized(prefixedKey))
29+
}
30+
31+
@Test
32+
fun `normalized rejects invalid keys`() {
33+
assertNull(PubkyPublicKeyFormat.normalized("pubkyshort"))
34+
assertNull(
35+
PubkyPublicKeyFormat.normalized(
36+
"pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot10",
37+
),
38+
)
39+
}
40+
41+
@Test
42+
fun `matches compares equivalent pubky representations`() {
43+
val rawKey = "ybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u"
44+
val prefixedKey = "pubky$rawKey"
45+
46+
assertTrue(PubkyPublicKeyFormat.matches(rawKey, prefixedKey))
47+
assertFalse(PubkyPublicKeyFormat.matches(prefixedKey, "pubkyinvalid"))
48+
}
49+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package to.bitkit.ui.screens.contacts
2+
3+
import android.content.Context
4+
import androidx.lifecycle.SavedStateHandle
5+
import kotlinx.coroutines.ExperimentalCoroutinesApi
6+
import kotlinx.coroutines.test.advanceUntilIdle
7+
import org.junit.Test
8+
import org.mockito.kotlin.any
9+
import org.mockito.kotlin.mock
10+
import org.mockito.kotlin.whenever
11+
import to.bitkit.R
12+
import to.bitkit.models.PubkyProfile
13+
import to.bitkit.repositories.PubkyContactError
14+
import to.bitkit.repositories.PubkyRepo
15+
import to.bitkit.test.BaseUnitTest
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertNull
18+
19+
@OptIn(ExperimentalCoroutinesApi::class)
20+
class AddContactViewModelTest : BaseUnitTest() {
21+
private val context: Context = mock()
22+
private val pubkyRepo: PubkyRepo = mock()
23+
24+
@Test
25+
fun `self add failure should show dedicated error`() = test {
26+
whenever(context.getString(R.string.contacts__add_error_self)).thenReturn("self error")
27+
whenever(pubkyRepo.fetchContactProfile(any()))
28+
.thenReturn(Result.failure(PubkyContactError.CannotAddSelf))
29+
30+
val sut = createSut()
31+
advanceUntilIdle()
32+
33+
assertEquals("self error", sut.uiState.value.error)
34+
assertNull(sut.uiState.value.fetchedProfile)
35+
}
36+
37+
@Test
38+
fun `invalid format failure should show dedicated error`() = test {
39+
whenever(context.getString(R.string.contacts__add_error_invalid_key)).thenReturn("invalid key")
40+
whenever(pubkyRepo.fetchContactProfile(any()))
41+
.thenReturn(Result.failure(PubkyContactError.InvalidFormat))
42+
43+
val sut = createSut()
44+
advanceUntilIdle()
45+
46+
assertEquals("invalid key", sut.uiState.value.error)
47+
assertNull(sut.uiState.value.fetchedProfile)
48+
}
49+
50+
@Test
51+
fun `successful fetch should populate profile`() = test {
52+
val profile = PubkyProfile.placeholder(TEST_PUBLIC_KEY)
53+
whenever(pubkyRepo.fetchContactProfile(TEST_PUBLIC_KEY)).thenReturn(Result.success(profile))
54+
55+
val sut = createSut()
56+
advanceUntilIdle()
57+
58+
assertEquals(profile, sut.uiState.value.fetchedProfile)
59+
assertNull(sut.uiState.value.error)
60+
}
61+
62+
private fun createSut(publicKey: String = TEST_PUBLIC_KEY): AddContactViewModel {
63+
return AddContactViewModel(
64+
context = context,
65+
pubkyRepo = pubkyRepo,
66+
savedStateHandle = SavedStateHandle(mapOf("publicKey" to publicKey)),
67+
)
68+
}
69+
70+
companion object {
71+
private const val TEST_PUBLIC_KEY = "pubkytest-contact"
72+
}
73+
}

0 commit comments

Comments
 (0)