diff --git a/app/src/main/kotlin/io/privkey/keep/nip46/Nip46ApprovalActivity.kt b/app/src/main/kotlin/io/privkey/keep/nip46/Nip46ApprovalActivity.kt index bbec6ec9..48ca18ac 100644 --- a/app/src/main/kotlin/io/privkey/keep/nip46/Nip46ApprovalActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/nip46/Nip46ApprovalActivity.kt @@ -94,6 +94,9 @@ class Nip46ApprovalActivity : FragmentActivity() { eventKind = request.eventKind?.toInt(), eventContent = request.eventContent, isConnectRequest = isConnect, + hasHttpAuth = request.httpAuth != null, + httpAuthUrl = request.httpAuth?.url, + httpAuthMethod = request.httpAuth?.method, onApprove = ::handleApprove, onReject = ::handleReject ) diff --git a/app/src/main/kotlin/io/privkey/keep/nip46/Nip46ApprovalScreen.kt b/app/src/main/kotlin/io/privkey/keep/nip46/Nip46ApprovalScreen.kt index ee5299c6..aa9ae4ff 100644 --- a/app/src/main/kotlin/io/privkey/keep/nip46/Nip46ApprovalScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/nip46/Nip46ApprovalScreen.kt @@ -26,6 +26,27 @@ private fun sanitizeDisplayContent(content: String): String { .take(500) } +/** + * The HTTP-auth (NIP-98, kind 27235) detail rows for the approval prompt as + * (label resource id, display value) pairs, or empty when the request carries no + * HTTP-auth. Gated on presence ([hasHttpAuth]), not field content: the signer core + * returns a present-but-empty httpAuth for a malformed 27235 (no `u` tag), and both + * rows must still render with [unspecified] so the missing target is flagged, never + * silently hidden. + */ +internal fun httpAuthRows( + hasHttpAuth: Boolean, + url: String?, + method: String?, + unspecified: String +): List> { + if (!hasHttpAuth) return emptyList() + return listOf( + R.string.connections_nip46_http_url to (url?.takeIf { it.isNotBlank() } ?: unspecified), + R.string.connections_nip46_http_method to (method?.takeIf { it.isNotBlank() } ?: unspecified) + ) +} + @Composable fun Nip46ApprovalScreen( appPubkey: String, @@ -34,6 +55,9 @@ fun Nip46ApprovalScreen( eventKind: Int?, eventContent: String?, isConnectRequest: Boolean = false, + hasHttpAuth: Boolean = false, + httpAuthUrl: String? = null, + httpAuthMethod: String? = null, onApprove: (duration: PermissionDuration, onComplete: (success: Boolean) -> Unit) -> Unit, onReject: () -> Unit ) { @@ -44,6 +68,11 @@ fun Nip46ApprovalScreen( val sanitizedContent = remember(eventContent) { eventContent?.let { sanitizeDisplayContent(it) } } + // NIP-98 (kind 27235) HTTP-auth: the security-relevant URL and method live in + // the event tags, not the (empty) content, so surface them so the user can see + // exactly which request they are authorizing before approving. + val sanitizedHttpUrl = remember(httpAuthUrl) { httpAuthUrl?.let { sanitizeDisplayContent(it) } } + val sanitizedHttpMethod = remember(httpAuthMethod) { httpAuthMethod?.let { sanitizeDisplayContent(it) } } val sanitizedAppName = remember(appName) { sanitizeDisplayContent(appName) } Column( @@ -109,6 +138,15 @@ fun Nip46ApprovalScreen( Nip46DetailRow(stringResource(R.string.connections_nip46_event_kind), EventKind.displayName(context, eventKind)) } + // Gate on PRESENCE, not field content: the signer core returns a + // present-but-empty httpAuth for a malformed kind-27235 so the prompt + // must flag the missing target ("Unspecified"), never hide it. + httpAuthRows(hasHttpAuth, sanitizedHttpUrl, sanitizedHttpMethod, stringResource(R.string.connections_nip46_http_unspecified)) + .forEach { (labelRes, value) -> + Spacer(modifier = Modifier.height(12.dp)) + Nip46DetailRow(stringResource(labelRes), value, valueMaxLines = 4) + } + if (!sanitizedContent.isNullOrBlank()) { Spacer(modifier = Modifier.height(12.dp)) Text( diff --git a/app/src/main/kotlin/io/privkey/keep/nip46/Nip46UiComponents.kt b/app/src/main/kotlin/io/privkey/keep/nip46/Nip46UiComponents.kt index 2431dfb4..82d9f4f8 100644 --- a/app/src/main/kotlin/io/privkey/keep/nip46/Nip46UiComponents.kt +++ b/app/src/main/kotlin/io/privkey/keep/nip46/Nip46UiComponents.kt @@ -21,13 +21,22 @@ import io.privkey.keep.nip55.PermissionDuration import io.privkey.keep.uniffi.Nip55RequestType @Composable -internal fun Nip46DetailRow(label: String, value: String) { +internal fun Nip46DetailRow( + label: String, + value: String, + valueMaxLines: Int = Int.MAX_VALUE +) { Text( text = label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Text(text = value, style = MaterialTheme.typography.bodyLarge) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + maxLines = valueMaxLines, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/res/values/strings_connections.xml b/app/src/main/res/values/strings_connections.xml index 9a1af964..da6178cb 100644 --- a/app/src/main/res/values/strings_connections.xml +++ b/app/src/main/res/values/strings_connections.xml @@ -129,6 +129,9 @@ Method Event Kind Content + HTTP URL + HTTP Method + Unspecified App Public Key Remember this decision Reject diff --git a/app/src/test/kotlin/io/privkey/keep/nip46/HttpAuthRowsTest.kt b/app/src/test/kotlin/io/privkey/keep/nip46/HttpAuthRowsTest.kt new file mode 100644 index 00000000..b43f2748 --- /dev/null +++ b/app/src/test/kotlin/io/privkey/keep/nip46/HttpAuthRowsTest.kt @@ -0,0 +1,43 @@ +package io.privkey.keep.nip46 + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The NIP-98 HTTP-auth rows must be gated on presence, not field content, so a + * malformed kind-27235 (present httpAuth but no `u` tag) still flags the missing + * target rather than hiding it. Assertions check row count + values (independent + * of resource-id resolution in JVM unit tests). + */ +class HttpAuthRowsTest { + private val unspecified = "Unspecified" + + @Test + fun noHttpAuth_yieldsNoRows() { + assertTrue(httpAuthRows(hasHttpAuth = false, url = "https://x", method = "GET", unspecified).isEmpty()) + } + + @Test + fun presentButBothBlank_stillShowsBothRowsAsUnspecified() { + val rows = httpAuthRows(hasHttpAuth = true, url = null, method = "", unspecified) + assertEquals(2, rows.size) + assertEquals(unspecified, rows[0].second) + assertEquals(unspecified, rows[1].second) + } + + @Test + fun presentWithValues_showsValues() { + val rows = httpAuthRows(hasHttpAuth = true, url = "https://api.example.com/x", method = "POST", unspecified) + assertEquals(2, rows.size) + assertEquals("https://api.example.com/x", rows[0].second) + assertEquals("POST", rows[1].second) + } + + @Test + fun oneBlank_fallsBackForThatFieldOnly() { + val rows = httpAuthRows(hasHttpAuth = true, url = "https://x", method = null, unspecified) + assertEquals("https://x", rows[0].second) + assertEquals(unspecified, rows[1].second) + } +}