Skip to content
Merged
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
// JVM-side native secp256k1 so unit tests can exercise ECDH (NIP-44 v3).
testImplementation(libs.secp256k1.jni.jvm)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.ui.test.junit4)
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@

<provider
android:name=".SignerProvider"
android:authorities="${applicationId}.PING;${applicationId}.SIGN_EVENT;${applicationId}.NIP04_ENCRYPT;${applicationId}.NIP04_DECRYPT;${applicationId}.NIP44_ENCRYPT;${applicationId}.NIP44_DECRYPT;${applicationId}.GET_PUBLIC_KEY;${applicationId}.DECRYPT_ZAP_EVENT"
android:authorities="${applicationId}.PING;${applicationId}.SIGN_EVENT;${applicationId}.NIP04_ENCRYPT;${applicationId}.NIP04_DECRYPT;${applicationId}.NIP44_ENCRYPT;${applicationId}.NIP44_DECRYPT;${applicationId}.NIP44_V3_ENCRYPT;${applicationId}.NIP44_V3_DECRYPT;${applicationId}.GET_PUBLIC_KEY;${applicationId}.DECRYPT_ZAP_EVENT"
android:enabled="true"
android:exported="true"
tools:ignore="ExportedContentProvider" />
Expand Down
84 changes: 69 additions & 15 deletions app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ import kotlinx.coroutines.runBlocking
class SignerProvider : ContentProvider() {
private val scope get() = Amber.instance.applicationIOScope

private fun rejectedCursor(): Cursor = MatrixCursor(arrayOf("rejected")).also { it.addRow(arrayOf("true")) }

// Decodes the Base64 v3 wire value to readable plaintext for history.
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
private fun nip44v3Plaintext(wireValue: String): String = try {
kotlin.io.encoding.Base64.decode(wireValue).toString(Charsets.UTF_8)
} catch (_: Exception) {
wireValue
}

override fun delete(
uri: Uri,
selection: String?,
Expand Down Expand Up @@ -224,6 +234,8 @@ class SignerProvider : ContentProvider() {
"content://$appId.NIP44_DECRYPT",
"content://$appId.NIP04_ENCRYPT",
"content://$appId.NIP44_ENCRYPT",
"content://$appId.NIP44_V3_DECRYPT",
"content://$appId.NIP44_V3_ENCRYPT",
"content://$appId.DECRYPT_ZAP_EVENT",
-> {
val content = projection?.first() ?: return null
Expand All @@ -240,11 +252,32 @@ class SignerProvider : ContentProvider() {
"NIP44_DECRYPT" -> SignerType.NIP44_DECRYPT
"NIP04_ENCRYPT" -> SignerType.NIP04_ENCRYPT
"NIP44_ENCRYPT" -> SignerType.NIP44_ENCRYPT
"NIP44_V3_DECRYPT" -> SignerType.NIP44_V3_DECRYPT
"NIP44_V3_ENCRYPT" -> SignerType.NIP44_V3_ENCRYPT
"DECRYPT_ZAP_EVENT" -> SignerType.DECRYPT_ZAP_EVENT
else -> null
} ?: return null

val isEncrypt = type == SignerType.NIP04_ENCRYPT || type == SignerType.NIP44_ENCRYPT
val isEncrypt = type == SignerType.NIP04_ENCRYPT ||
type == SignerType.NIP44_ENCRYPT ||
type == SignerType.NIP44_V3_ENCRYPT
val isV3 = type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT

// V3 carries kind and scope as projection[3] and projection[4].
// A missing/invalid kind is a malformed request: auto-reject
// instead of prompting the user.
val (v3Kind, v3Scope) = if (isV3) {
val kindStr = projection.getOrNull(3)
val scopeStr = projection.getOrNull(4) ?: ""
val parsedKind = kindStr?.toIntOrNull()
if (parsedKind == null) {
Log.d(Amber.TAG, "NIP-44 v3 request missing/invalid kind")
return rejectedCursor()
}
parsedKind to scopeStr
} else {
null to ""
}

// For ENCRYPT: classify plaintext input; for DECRYPT: perform operation first then classify result
val result =
Expand All @@ -258,6 +291,8 @@ class SignerProvider : ContentProvider() {
type,
account,
pubkey,
v3Kind,
v3Scope,
) ?: "Could not decrypt the message"
}
} catch (e: Exception) {
Expand All @@ -272,22 +307,32 @@ class SignerProvider : ContentProvider() {
),
)
}
// A V3 decrypt that throws cannot succeed (wrong
// version/context, bad MAC, corrupt padding, ...):
// reject it rather than prompting the user.
if (isV3) return rejectedCursor()
"Could not decrypt the message"
}
}

// Classify the content to determine EncryptedDataKind-based permission type
val classifyContent = if (isEncrypt) content else (result ?: content)
val permType = permissionTypeFromContent(classifyContent, isEncrypt, type)

var permission = permDao.getPermission(packageName, permType)
if (permission == null) {
permission = permDao.getPermission(
packageName,
type.toString(),
)
// Permission lookup. V3 grants are scoped by (packageName,
// SignerType, kind); fall back to a kind=null "all kinds"
// grant. V3 grants do NOT satisfy V2 requests and vice versa.
var permission = if (isV3) {
// V3 grants are kind-scoped; fall back to the explicit
// "all kinds" (kind IS NULL) grant only, never to any
// other kind — otherwise e.g. a kind-A reject would
// leak to a kind-B request.
permDao.getPermission(packageName, type.toString(), v3Kind!!)
?: permDao.getPermissionAllKinds(packageName, type.toString())
} else {
// Classify the content to determine EncryptedDataKind-based permission type
val classifyContent = if (isEncrypt) content else (result ?: content)
val permType = permissionTypeFromContent(classifyContent, isEncrypt, type)
permDao.getPermission(packageName, permType)
?: permDao.getPermission(packageName, type.toString())
}
if (permission == null) {
if (permission == null && !isV3) {
val nip = when (stringType) {
"NIP04_DECRYPT" -> 4
"NIP44_DECRYPT" -> 44
Expand Down Expand Up @@ -316,7 +361,7 @@ class SignerProvider : ContentProvider() {
0,
packageName,
uriString.replace("content://$appId.", ""),
null,
v3Kind,
TimeUtils.now(),
false,
content = content,
Expand All @@ -343,6 +388,8 @@ class SignerProvider : ContentProvider() {
type,
account,
pubkey,
v3Kind,
v3Scope,
) ?: "Could not decrypt the message"
}
} catch (e: Exception) {
Expand All @@ -360,17 +407,24 @@ class SignerProvider : ContentProvider() {
"Could not decrypt the message"
}

// For v3 the wire value is Base64; this auto-accept path has no
// EncryptedDataKind, so decode it once for the readable log.
val historyContent = if (isV3) {
nip44v3Plaintext(if (!isEncrypt) finalResult else content)
} else {
if (!isEncrypt) finalResult else content
}
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
null,
v3Kind,
TimeUtils.now(),
true,
content = if (!isEncrypt) finalResult else content,
content = historyContent,
),
),
account.npub,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ interface ApplicationDao {
type: String,
): ApplicationPermissionsEntity?

/**
* Match an "all kinds" grant — the row whose `kind` column is `NULL`.
* Distinct from [getPermission] (no kind), which matches any row of the
* given type regardless of kind: V3's kind-scoped permission model needs
* the explicit `IS NULL` to avoid kind-A rejects leaking to kind-B requests.
*/
@Query("SELECT * FROM applicationPermission WHERE pkKey = :key AND type = :type AND kind IS NULL AND relay = '' LIMIT 1")
fun getPermissionAllKinds(
key: String,
type: String,
): ApplicationPermissionsEntity?

@Query("SELECT * FROM applicationPermission WHERE pkKey = :key AND type = :type AND kind = :kind AND relay = :relay LIMIT 1")
fun getPermissionForRelay(
key: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import androidx.paging.PagingSource
class CachingApplicationDao(
private val delegate: ApplicationDao,
) : ApplicationDao {
private enum class Method { SIGN_POLICY, PERM, PERM_KIND, PERM_RELAY, PERM_WILDCARD }
private enum class Method { SIGN_POLICY, PERM, PERM_KIND, PERM_ALL_KINDS, PERM_RELAY, PERM_WILDCARD }

private data class Key(
val method: Method,
Expand Down Expand Up @@ -81,6 +81,14 @@ class CachingApplicationDao(
return result
}

override fun getPermissionAllKinds(key: String, type: String): ApplicationPermissionsEntity? {
val k = Key(Method.PERM_ALL_KINDS, key, type, null, null)
lookup(k)?.let { return it.permission }
val result = delegate.getPermissionAllKinds(key, type)
store(k, Value(null, result))
return result
}

override fun getPermissionForRelay(key: String, type: String, kind: Int, relay: String): ApplicationPermissionsEntity? {
val k = Key(Method.PERM_RELAY, key, type, kind, relay)
lookup(k)?.let { return it.permission }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import androidx.compose.ui.platform.Clipboard
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.DataStoreAccess
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.service.nip44v3.Nip44v3
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
Expand Down Expand Up @@ -84,6 +86,10 @@ class Account(

suspend fun nip04Decrypt(cipherText: String, fromPublicKey: String): String = signer.nip04Decrypt(cipherText, fromPublicKey)

fun nip44v3Encrypt(plainText: ByteArray, toPublicKey: String, kind: Int, scope: String): String = Nip44v3.encrypt(plainText, signer.keyPair.privKey!!, toPublicKey.hexToByteArray(), kind, scope)

fun nip44v3Decrypt(cipherText: String, fromPublicKey: String, kind: Int, scope: String): ByteArray = Nip44v3.decrypt(cipherText, signer.keyPair.privKey!!, fromPublicKey.hexToByteArray(), kind, scope)

suspend fun decrypt(encryptedContent: String, fromPublicKey: String): String = signer.decrypt(encryptedContent, fromPublicKey)

suspend fun signPsbt(psbtHex: String): String = signer.signPsbt(psbtHex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,25 @@ fun EncryptedDataKind?.toPermissionType(isEncrypt: Boolean): String = when (this
fun SignerType.toPermissionTypeString(encryptedData: EncryptedDataKind?): String = when (this) {
SignerType.NIP04_ENCRYPT, SignerType.NIP44_ENCRYPT -> encryptedData.toPermissionType(isEncrypt = true)
SignerType.NIP04_DECRYPT, SignerType.NIP44_DECRYPT -> encryptedData.toPermissionType(isEncrypt = false)
SignerType.NIP44_V3_ENCRYPT, SignerType.NIP44_V3_DECRYPT -> this.toString()
SignerType.DECRYPT_ZAP_EVENT -> "DECRYPT_ZAP_EVENT"
else -> this.toString()
}

/**
* The readable NIP-44 v3 plaintext. For v3 the data kind stores the real
* plaintext in `text` and the Base64 wire value in `result`, so history/display
* read `text` and never touch the wire encoding.
*/
fun EncryptedDataKind?.nip44v3Plaintext(): String = (this as? ClearTextEncryptedDataKind)?.text ?: this?.result ?: ""

val encryptDecryptSignerTypes = setOf(
SignerType.NIP04_ENCRYPT,
SignerType.NIP44_ENCRYPT,
SignerType.NIP04_DECRYPT,
SignerType.NIP44_DECRYPT,
SignerType.NIP44_V3_ENCRYPT,
SignerType.NIP44_V3_DECRYPT,
SignerType.DECRYPT_ZAP_EVENT,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@ data class IntentData(
val encryptedData: EncryptedDataKind?,
val isNostrConnectURI: Boolean = false,
val unsignedEventKey: HexKey = "",
// NIP-44 v3 context fields. Required when [type] is one of the
// NIP44_V3_* variants; ignored otherwise.
val nip44v3Kind: Int? = null,
val nip44v3Scope: String = "",
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ enum class SignerType {
NIP04_DECRYPT,
NIP44_ENCRYPT,
NIP44_DECRYPT,
NIP44_V3_ENCRYPT,
NIP44_V3_DECRYPT,
GET_PUBLIC_KEY,
DECRYPT_ZAP_EVENT,
PING,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,21 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.TimeUtils

object AmberUtils {
/**
* Produces the wire value for a request. Per the nip44v3 NIP-46 draft, v3
* plaintext travels Base64-encoded: encrypt receives base64(plaintext) and
* returns the ciphertext; decrypt returns base64(plaintext). The readable
* plaintext for logs/display comes from the EncryptedDataKind instead (see
* [nip44v3Plaintext]). [nip44v3Kind]/[nip44v3Scope] are required for V3.
*/
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
suspend fun encryptOrDecryptData(
data: String,
type: SignerType,
account: Account,
pubKey: HexKey,
nip44v3Kind: Int? = null,
nip44v3Scope: String = "",
): String? = when (type) {
SignerType.DECRYPT_ZAP_EVENT -> {
account.decryptZapEvent(data)
Expand All @@ -42,6 +52,14 @@ object AmberUtils {
SignerType.NIP44_ENCRYPT -> {
account.nip44Encrypt(data, pubKey)
}
SignerType.NIP44_V3_ENCRYPT -> {
requireNotNull(nip44v3Kind) { "kind is required for NIP44_V3_ENCRYPT" }
account.nip44v3Encrypt(kotlin.io.encoding.Base64.decode(data), pubKey, nip44v3Kind, nip44v3Scope)
}
SignerType.NIP44_V3_DECRYPT -> {
requireNotNull(nip44v3Kind) { "kind is required for NIP44_V3_DECRYPT" }
kotlin.io.encoding.Base64.encode(account.nip44v3Decrypt(data, pubKey, nip44v3Kind, nip44v3Scope))
}
else -> {
account.nip44Decrypt(data, pubKey)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.greenart7c3.nostrsigner.models.AmberBunkerRequest
import com.greenart7c3.nostrsigner.models.EncryptionType
import com.greenart7c3.nostrsigner.models.Permission
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.nip44v3Plaintext
import com.greenart7c3.nostrsigner.relays.AmberListenerSingleton
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.ui.RememberType
Expand Down Expand Up @@ -228,6 +229,8 @@ object BunkerRequestUtils {
"nip04_decrypt" -> SignerType.NIP04_DECRYPT
"nip44_encrypt" -> SignerType.NIP44_ENCRYPT
"nip44_decrypt" -> SignerType.NIP44_DECRYPT
"nip44v3_encrypt" -> SignerType.NIP44_V3_ENCRYPT
"nip44v3_decrypt" -> SignerType.NIP44_V3_DECRYPT
"decrypt_zap_event" -> SignerType.DECRYPT_ZAP_EVENT
"ping" -> SignerType.PING
"switch_relays" -> SignerType.SWITCH_RELAYS
Expand All @@ -242,11 +245,21 @@ object BunkerRequestUtils {
amberEvent.toEvent().toJson()
}
"nip04_encrypt", "nip04_decrypt", "nip44_encrypt", "nip44_decrypt", "decrypt_zap_event" -> bunkerRequest.params.getOrElse(1) { "" }
// NIP-44 v3 NIP-46 layout: [pubkey, kind, scope, payload]. For
// `nip44v3_encrypt` the payload is base64-encoded plaintext; for
// `nip44v3_decrypt` it is the v3 ciphertext.
"nip44v3_encrypt", "nip44v3_decrypt" -> bunkerRequest.params.getOrElse(3) { "" }
"ping" -> "pong"
"sign_psbt" -> bunkerRequest.params.first()
else -> ""
}

/** Extract `kind` from a NIP-44 v3 bunker request; null if missing/invalid. */
fun getNip44v3Kind(bunkerRequest: BunkerRequest): Int? = bunkerRequest.params.getOrNull(1)?.toIntOrNull()

/** Extract `scope` from a NIP-44 v3 bunker request; defaults to empty per spec. */
fun getNip44v3Scope(bunkerRequest: BunkerRequest): String = bunkerRequest.params.getOrElse(2) { "" }

fun sendResult(
context: Context,
account: Account,
Expand Down Expand Up @@ -420,6 +433,11 @@ object BunkerRequestUtils {
SignerType.NIP44_DECRYPT,
SignerType.DECRYPT_ZAP_EVENT,
-> response
// v3 wire values are Base64; log the readable plaintext
// already decoded into encryptedData.
SignerType.NIP44_V3_ENCRYPT,
SignerType.NIP44_V3_DECRYPT,
-> bunkerRequest.encryptedData.nip44v3Plaintext()
else -> getDataFromBunker(bunkerRequest.request)
},
),
Expand Down
Loading
Loading