Add AIDL signer service as alternative to SignerProvider

Introduce an ISignerService AIDL bound-service interface so external apps
can sign, encrypt/decrypt, sign PSBTs, and ping the signer over a direct
binder connection instead of issuing content:// ContentProvider queries.

The per-operation logic (permission lookups, history recording, error
logging, NIP-42 relay whitelist handling, encrypted-data classification)
is extracted into a shared SignerCore object so the ContentProvider and
the new SignerService stay in sync. SignerProvider is refactored to
delegate to SignerCore and map results to its existing cursor columns
(PING keeps its legacy two-column shape for backwards compatibility).

The service returns a Bundle mirroring the legacy cursor columns
(rejected / signature / event / result); a null return signals an error,
matching the ContentProvider's null-cursor behaviour. AIDL builds are
enabled via the aidl build feature.
This commit is contained in:
Claude
2026-05-28 18:52:35 +00:00
parent 339f8cbc88
commit a8db4fcda2
6 changed files with 559 additions and 558 deletions
+1
View File
@@ -156,6 +156,7 @@ android {
compose = true
buildConfig = true
resValues = true
aidl = true
}
packaging {
+9
View File
@@ -63,6 +63,15 @@
android:exported="true"
tools:ignore="ExportedContentProvider" />
<service
android:name=".SignerService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="com.greenart7c3.nostrsigner.SIGNER_SERVICE" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -0,0 +1,44 @@
// ISignerService.aidl
package com.greenart7c3.nostrsigner;
/**
* Bound-service signing interface — a synchronous alternative to the
* {@code SignerProvider} ContentProvider.
*
* Clients bind to {@code com.greenart7c3.nostrsigner.SignerService} (action
* {@code com.greenart7c3.nostrsigner.SIGNER_SERVICE}) and call these methods
* directly instead of issuing {@code content://} queries.
*
* Every method returns a {@link android.os.Bundle} that mirrors the columns the
* ContentProvider used to return:
* - {@code "rejected"} (boolean) — present and {@code true} when no auto-accept
* rule matched, meaning the request must be confirmed by the user through the
* normal {@code nostrsigner:} intent flow.
* - {@code "signature"}, {@code "event"}, {@code "result"} (String) — the
* operation output, populated on success.
*
* A {@code null} return signals an error (unknown account, malformed input,
* missing permission record, etc.), matching the ContentProvider's {@code null}
* cursor behaviour.
*
* The {@code npub} parameter selects the signing account (hex pubkey or npub).
*/
interface ISignerService {
Bundle signMessage(String message, String npub);
Bundle signEvent(String eventJson, String npub);
Bundle nip04Encrypt(String plaintext, String pubKey, String npub);
Bundle nip04Decrypt(String ciphertext, String pubKey, String npub);
Bundle nip44Encrypt(String plaintext, String pubKey, String npub);
Bundle nip44Decrypt(String ciphertext, String pubKey, String npub);
Bundle decryptZapEvent(String eventJson, String pubKey, String npub);
Bundle signPsbt(String psbtHex, String npub);
Bundle ping(String npub);
}
@@ -0,0 +1,363 @@
package com.greenart7c3.nostrsigner
import android.content.Context
import android.util.Log
import com.greenart7c3.nostrsigner.database.HistoryDatabase
import com.greenart7c3.nostrsigner.database.HistoryEntity
import com.greenart7c3.nostrsigner.database.LogDatabase
import com.greenart7c3.nostrsigner.database.LogEntity
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.kindToNip
import com.greenart7c3.nostrsigner.models.permissionTypeFromContent
import com.greenart7c3.nostrsigner.service.AmberUtils
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.RelayUrlUtils
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/**
* Shared signing logic backing both request paths that reach the signer
* synchronously: the [SignerProvider] ContentProvider and the [SignerService]
* AIDL bound service.
*
* Each operation queries the per-account permission database, records history,
* and either performs the operation or reports that explicit user approval is
* still required. The two transports differ only in how they marshal the
* returned [SignResult] (cursor columns vs. a [android.os.Bundle]).
*/
object SignerCore {
private val scope get() = Amber.instance.applicationIOScope
/** Outcome of a synchronous signer operation. `null` (not modelled here) means error. */
sealed interface SignResult {
/** No auto-accept rule matched; the request must be confirmed by the user. */
data object Rejected : SignResult
/** Operation completed. Field meanings mirror the legacy cursor columns. */
data class Reply(
val signature: String?,
val event: String?,
val result: String?,
) : SignResult
}
fun signMessage(
context: Context,
packageName: String,
message: String,
npubInput: String,
): SignResult? {
val npub = IntentUtils.parsePubKey(npubInput) ?: run {
Log.d(Amber.TAG, "No npub")
return null
}
val account = LocalPreferences.loadFromEncryptedStorageSync(context, npub) ?: run {
Log.d(Amber.TAG, "No account from storage")
return null
}
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
val permission = permDao.getPermission(packageName, "SIGN_MESSAGE")
val signPolicy = permDao.getSignPolicy(packageName)
val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) {
recordHistory(historyDatabase, account.npub, packageName, "SIGN_MESSAGE", null, false, message)
return SignResult.Rejected
}
val result = runBlocking { account.signString(message) }
recordHistory(historyDatabase, account.npub, packageName, "SIGN_MESSAGE", null, true, message)
return SignResult.Reply(result, result, result)
}
fun signEvent(
context: Context,
packageName: String,
json: String,
npubInput: String,
): SignResult? {
val npub = IntentUtils.parsePubKey(npubInput) ?: run {
Log.d(Amber.TAG, "No npub")
return null
}
val account = LocalPreferences.loadFromEncryptedStorageSync(context, npub) ?: run {
Log.d(Amber.TAG, "No account from storage")
return null
}
val event = try {
IntentUtils.getUnsignedEvent(json, account)
} catch (e: Exception) {
Log.d(Amber.TAG, "Failed to parse event from $packageName", e)
return null
}
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
// For kind 22242 (NIP-42 relay auth), extract relay host once for both whitelist and permission checks
val relayHost = if (event.kind == 22242) {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(event))
} else {
""
}
val whitelistAutoAccept = if (event.kind == 22242) {
val authWhitelist = Amber.instance.settings.authWhitelist
when {
authWhitelist.isEmpty() -> false
relayHost in authWhitelist -> true
else -> {
recordHistory(historyDatabase, account.npub, packageName, "SIGN_EVENT", event.kind, false, event.toJson())
return SignResult.Rejected
}
}
} else {
false
}
var permission = if (event.kind == 22242) {
// Kind 22242 = relay client auth (NIP-42): check relay-specific permission first
permDao.getPermissionForRelay(packageName, "SIGN_EVENT", 22242, relayHost)
?: permDao.getWildcardRelayPermission(packageName, "SIGN_EVENT", 22242)
} else {
permDao.getPermission(packageName, "SIGN_EVENT", event.kind)
}
if (permission == null && event.kind != 22242) {
event.kind.kindToNip()?.let {
val nipNumber = it.toIntOrNull()
permission = if (nipNumber == null) {
null
} else {
permDao.getPermission(packageName, "NIP", nipNumber)
}
}
}
val signPolicy = permDao.getSignPolicy(packageName)
val isRemembered = whitelistAutoAccept || IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) {
recordHistory(historyDatabase, account.npub, packageName, "SIGN_EVENT", event.kind, false, event.toJson())
return SignResult.Rejected
}
val signedEvent = account.signSync<Event>(event.createdAt, event.kind, event.tags, event.content)
recordHistory(historyDatabase, account.npub, packageName, "SIGN_EVENT", event.kind, true, signedEvent.toJson())
val signature =
if (event.kind == LnZapRequestEvent.KIND &&
event.tags.any { tag -> tag.any { t -> t == "anon" } }
) {
signedEvent.toJson()
} else {
signedEvent.sig
}
return SignResult.Reply(signature, signedEvent.toJson(), signature)
}
fun encryptOrDecrypt(
context: Context,
packageName: String,
type: SignerType,
content: String,
pubKey: String,
npubInput: String,
): SignResult? {
val npub = IntentUtils.parsePubKey(npubInput) ?: return null
val stringType = type.toString()
val account = LocalPreferences.loadFromEncryptedStorageSync(context, npub) ?: return null
val logDatabase = Amber.instance.getLogDatabase(account.npub)
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
val isEncrypt = type == SignerType.NIP04_ENCRYPT || type == SignerType.NIP44_ENCRYPT
// For ENCRYPT: classify plaintext input; for DECRYPT: perform operation first then classify result
val result =
if (isEncrypt) {
null // defer until after permission check
} else {
try {
runBlocking {
AmberUtils.encryptOrDecryptData(content, type, account, pubKey)
?: "Could not decrypt the message"
}
} catch (e: Exception) {
recordLog(logDatabase, packageName, stringType, e.message ?: "Could not decrypt the message")
"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())
}
if (permission == null) {
val nip = when (type) {
SignerType.NIP04_DECRYPT, SignerType.NIP04_ENCRYPT -> 4
SignerType.NIP44_DECRYPT, SignerType.NIP44_ENCRYPT -> 44
else -> null
}
nip?.let {
permission = permDao.getPermission(packageName, "NIP", it)
}
}
val signPolicy = permDao.getSignPolicy(packageName)
val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) {
recordHistory(historyDatabase, account.npub, packageName, stringType, null, false, content)
return SignResult.Rejected
}
// For encrypt: perform the operation now after permission is confirmed
val finalResult =
result ?: try {
runBlocking {
AmberUtils.encryptOrDecryptData(content, type, account, pubKey)
?: "Could not decrypt the message"
}
} catch (e: Exception) {
recordLog(logDatabase, packageName, stringType, e.message ?: "Could not decrypt the message")
"Could not decrypt the message"
}
recordHistory(historyDatabase, account.npub, packageName, stringType, null, true, if (!isEncrypt) finalResult else content)
return SignResult.Reply(finalResult, finalResult, finalResult)
}
fun signPsbt(
context: Context,
packageName: String,
psbtHex: String,
npubInput: String,
): SignResult? {
val npub = IntentUtils.parsePubKey(npubInput) ?: run {
Log.d(Amber.TAG, "No npub")
return null
}
val account = LocalPreferences.loadFromEncryptedStorageSync(context, npub) ?: run {
Log.d(Amber.TAG, "No account from storage")
return null
}
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
val permission = permDao.getPermission(packageName, "SIGN_PSBT")
val signPolicy = permDao.getSignPolicy(packageName)
val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) {
recordHistory(historyDatabase, account.npub, packageName, "SIGN_PSBT", null, false, psbtHex)
return SignResult.Rejected
}
val result = try {
runBlocking { account.signPsbt(psbtHex) }
} catch (e: Exception) {
Log.d(Amber.TAG, "Failed to sign psbt", e)
recordLog(Amber.instance.getLogDatabase(account.npub), packageName, "SIGN_PSBT", e.message ?: "Could not sign the psbt")
return null
}
recordHistory(historyDatabase, account.npub, packageName, "SIGN_PSBT", null, true, psbtHex)
return SignResult.Reply(result, result, result)
}
fun ping(
context: Context,
packageName: String,
npubInput: String?,
): SignResult? {
val npub = if (!npubInput.isNullOrBlank()) IntentUtils.parsePubKey(npubInput) else null
val account = if (npub != null) {
LocalPreferences.loadFromEncryptedStorageSync(context, npub)
} else {
LocalPreferences.allSavedAccounts(context).firstNotNullOfOrNull { accountInfo ->
val localDatabase = Amber.instance.getDatabase(accountInfo.npub)
val hasAccount = localDatabase.dao().getByKeySync(packageName) != null
if (hasAccount) {
LocalPreferences.loadFromEncryptedStorageSync(context, accountInfo.npub)
} else {
null
}
}
} ?: return null
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
val permission = permDao.getPermission(packageName, "PING")
val signPolicy = permDao.getSignPolicy(packageName)
val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) {
recordHistory(historyDatabase, account.npub, packageName, "PING", null, false)
return SignResult.Rejected
}
recordHistory(historyDatabase, account.npub, packageName, "PING", null, true)
return SignResult.Reply("pong", null, "pong")
}
/** Logs an unexpected error against every saved account, matching the legacy catch-all. */
fun logError(
context: Context,
packageName: String,
type: String,
message: String,
) {
scope.launch {
LocalPreferences.allSavedAccounts(context).forEach { accInfo ->
recordLog(Amber.instance.getLogDatabase(accInfo.npub), packageName, type, message)
}
}
}
private fun recordHistory(
historyDatabase: HistoryDatabase,
npub: String,
packageName: String,
type: String,
kind: Int?,
accepted: Boolean,
content: String = "",
) {
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
type,
kind,
TimeUtils.now(),
accepted,
content = content,
),
),
npub,
)
}
}
private fun recordLog(
logDatabase: LogDatabase,
packageName: String,
type: String,
message: String,
) {
scope.launch {
logDatabase.dao().insertLog(
LogEntity(
0,
packageName,
type,
message,
System.currentTimeMillis(),
),
)
}
}
}
@@ -6,25 +6,10 @@ import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.util.Log
import com.greenart7c3.nostrsigner.database.HistoryEntity
import com.greenart7c3.nostrsigner.database.LogEntity
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.kindToNip
import com.greenart7c3.nostrsigner.models.permissionTypeFromContent
import com.greenart7c3.nostrsigner.service.AmberUtils
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.RelayUrlUtils
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class SignerProvider : ContentProvider() {
private val scope get() = Amber.instance.applicationIOScope
override fun delete(
uri: Uri,
@@ -65,235 +50,22 @@ class SignerProvider : ContentProvider() {
Log.d(Amber.TAG, "No package name")
return null
}
val localContext = context ?: return null
return try {
when (uriString) {
"content://$appId.SIGN_MESSAGE" -> {
val message = projection?.first()
if (message == null) {
val message = projection?.first() ?: run {
Log.d(Amber.TAG, "No message")
return null
}
val npub = IntentUtils.parsePubKey(projection[2])
if (npub == null) {
Log.d(Amber.TAG, "No npub")
return null
}
val account = LocalPreferences.loadFromEncryptedStorageSync(context!!, npub)
if (account == null) {
Log.d(Amber.TAG, "No account from storage")
return null
}
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
val permission =
permDao
.getPermission(
packageName,
"SIGN_MESSAGE",
)
val signPolicy = permDao.getSignPolicy(packageName)
val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) {
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
null,
TimeUtils.now(),
false,
content = message,
),
),
account.npub,
)
}
val cursor =
MatrixCursor(arrayOf("rejected")).also {
it.addRow(arrayOf("true"))
}
return cursor
}
val result = runBlocking { account.signString(message) }
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
"SIGN_MESSAGE",
null,
TimeUtils.now(),
true,
content = message,
),
),
account.npub,
)
}
val localCursor = MatrixCursor(arrayOf("signature", "event", "result")).also {
it.addRow(arrayOf(result, result, result))
}
return localCursor
SignerCore.signMessage(localContext, packageName, message, projection[2])?.toCursor()
}
"content://$appId.SIGN_EVENT" -> {
val json = projection?.first()
if (json == null) {
val json = projection?.first() ?: run {
Log.d(Amber.TAG, "No json")
return null
}
val npub = IntentUtils.parsePubKey(projection[2])
if (npub == null) {
Log.d(Amber.TAG, "No npub")
return null
}
val account = LocalPreferences.loadFromEncryptedStorageSync(context!!, npub)
if (account == null) {
Log.d(Amber.TAG, "No account from storage")
return null
}
val event = try {
IntentUtils.getUnsignedEvent(json, account)
} catch (e: Exception) {
Log.d(Amber.TAG, "Failed to parse event from $packageName", e)
return null
}
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
// For kind 22242 (NIP-42 relay auth), extract relay host once for both whitelist and permission checks
val relayHost = if (event.kind == 22242) {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(event))
} else {
""
}
val whitelistAutoAccept = if (event.kind == 22242) {
val authWhitelist = Amber.instance.settings.authWhitelist
when {
authWhitelist.isEmpty() -> false
relayHost in authWhitelist -> true
else -> {
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
event.kind,
TimeUtils.now(),
false,
content = event.toJson(),
),
),
account.npub,
)
}
return MatrixCursor(arrayOf("rejected")).also { it.addRow(arrayOf("true")) }
}
}
} else {
false
}
var permission = if (event.kind == 22242) {
// Kind 22242 = relay client auth (NIP-42): check relay-specific permission first
permDao.getPermissionForRelay(packageName, "SIGN_EVENT", 22242, relayHost)
?: permDao.getWildcardRelayPermission(packageName, "SIGN_EVENT", 22242)
} else {
permDao
.getPermission(
packageName,
"SIGN_EVENT",
event.kind,
)
}
if (permission == null && event.kind != 22242) {
event.kind.kindToNip()?.let {
val nipNumber = it.toIntOrNull()
permission = if (nipNumber == null) {
null
} else {
permDao
.getPermission(
packageName,
"NIP",
nipNumber,
)
}
}
}
val signPolicy = permDao.getSignPolicy(packageName)
val isRemembered = whitelistAutoAccept || IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) {
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
event.kind,
TimeUtils.now(),
false,
content = event.toJson(),
),
),
account.npub,
)
}
val cursor =
MatrixCursor(arrayOf("rejected")).also {
it.addRow(arrayOf("true"))
}
return cursor
}
val signedEvent = account.signSync<Event>(event.createdAt, event.kind, event.tags, event.content)
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
"SIGN_EVENT",
event.kind,
TimeUtils.now(),
true,
content = signedEvent.toJson(),
),
),
account.npub,
)
}
val cursor =
MatrixCursor(arrayOf("signature", "event", "result")).also {
val signature =
if (event.kind == LnZapRequestEvent.KIND &&
event.tags.any { tag ->
tag.any { t -> t == "anon" }
}
) {
signedEvent.toJson()
} else {
signedEvent.sig
}
it.addRow(arrayOf(signature, signedEvent.toJson(), signature))
}
return cursor
SignerCore.signEvent(localContext, packageName, json, projection[2])?.toCursor()
}
"content://$appId.NIP04_DECRYPT",
"content://$appId.NIP44_DECRYPT",
@@ -302,15 +74,8 @@ class SignerProvider : ContentProvider() {
"content://$appId.DECRYPT_ZAP_EVENT",
-> {
val content = projection?.first() ?: return null
val npub = IntentUtils.parsePubKey(projection[2]) ?: return null
val stringType = uriString.replace("content://$appId.", "")
val pubkey = projection[1]
val account = LocalPreferences.loadFromEncryptedStorageSync(context!!, npub) ?: return null
val logDatabase = Amber.instance.getLogDatabase(account.npub)
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
val type =
when (stringType) {
val type = when (uriString.replace("content://$appId.", "")) {
"NIP04_DECRYPT" -> SignerType.NIP04_DECRYPT
"NIP44_DECRYPT" -> SignerType.NIP44_DECRYPT
"NIP04_ENCRYPT" -> SignerType.NIP04_ENCRYPT
@@ -318,333 +83,45 @@ class SignerProvider : ContentProvider() {
"DECRYPT_ZAP_EVENT" -> SignerType.DECRYPT_ZAP_EVENT
else -> null
} ?: return null
val isEncrypt = type == SignerType.NIP04_ENCRYPT || type == SignerType.NIP44_ENCRYPT
// For ENCRYPT: classify plaintext input; for DECRYPT: perform operation first then classify result
val result =
if (isEncrypt) {
null // defer until after permission check
} else {
try {
runBlocking {
AmberUtils.encryptOrDecryptData(
content,
type,
account,
pubkey,
) ?: "Could not decrypt the message"
SignerCore.encryptOrDecrypt(localContext, packageName, type, content, pubkey, projection[2])?.toCursor()
}
} catch (e: Exception) {
scope.launch {
logDatabase.dao().insertLog(
LogEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
e.message ?: "Could not decrypt the message",
System.currentTimeMillis(),
),
)
}
"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(),
)
}
if (permission == null) {
val nip = when (stringType) {
"NIP04_DECRYPT" -> 4
"NIP44_DECRYPT" -> 44
"NIP04_ENCRYPT" -> 4
"NIP44_ENCRYPT" -> 44
"DECRYPT_ZAP_EVENT" -> null
else -> null
}
nip?.let {
permission =
permDao
.getPermission(
packageName,
"NIP",
it,
)
}
}
val signPolicy = permDao.getSignPolicy(packageName)
val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) {
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
null,
TimeUtils.now(),
false,
content = content,
),
),
account.npub,
)
}
val cursor =
MatrixCursor(arrayOf("rejected")).also {
it.addRow(arrayOf("true"))
}
return cursor
}
// For encrypt: perform the operation now after permission is confirmed
val finalResult =
result ?: try {
runBlocking {
AmberUtils.encryptOrDecryptData(
content,
type,
account,
pubkey,
) ?: "Could not decrypt the message"
}
} catch (e: Exception) {
scope.launch {
logDatabase.dao().insertLog(
LogEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
e.message ?: "Could not decrypt the message",
System.currentTimeMillis(),
),
)
}
"Could not decrypt the message"
}
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
null,
TimeUtils.now(),
true,
content = if (!isEncrypt) finalResult else content,
),
),
account.npub,
)
}
val cursor = MatrixCursor(arrayOf("signature", "event", "result"))
cursor.addRow(arrayOf<Any>(finalResult, finalResult, finalResult))
return cursor
}
"content://$appId.SIGN_PSBT" -> {
val psbtHex = projection?.first()
if (psbtHex == null) {
val psbtHex = projection?.first() ?: run {
Log.d(Amber.TAG, "No psbt")
return null
}
val npub = IntentUtils.parsePubKey(projection[2])
if (npub == null) {
Log.d(Amber.TAG, "No npub")
return null
}
val account = LocalPreferences.loadFromEncryptedStorageSync(context!!, npub)
if (account == null) {
Log.d(Amber.TAG, "No account from storage")
return null
}
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
val permission =
permDao
.getPermission(
packageName,
"SIGN_PSBT",
)
val signPolicy = permDao.getSignPolicy(packageName)
val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) {
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
null,
TimeUtils.now(),
false,
content = psbtHex,
),
),
account.npub,
)
}
val cursor =
MatrixCursor(arrayOf("rejected")).also {
it.addRow(arrayOf("true"))
}
return cursor
}
val result = try {
runBlocking { account.signPsbt(psbtHex) }
} catch (e: Exception) {
Log.d(Amber.TAG, "Failed to sign psbt", e)
scope.launch {
val logDb = Amber.instance.getLogDatabase(account.npub)
logDb.dao().insertLog(
LogEntity(
0,
packageName,
"SIGN_PSBT",
e.message ?: "Could not sign the psbt",
System.currentTimeMillis(),
),
)
}
return null
}
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
"SIGN_PSBT",
null,
TimeUtils.now(),
true,
content = psbtHex,
),
),
account.npub,
)
}
val localCursor = MatrixCursor(arrayOf("signature", "event", "result")).also {
it.addRow(arrayOf(result, result, result))
}
return localCursor
SignerCore.signPsbt(localContext, packageName, psbtHex, projection[2])?.toCursor()
}
"content://$appId.PING" -> {
val npub = if (projection != null && projection.isNotEmpty()) IntentUtils.parsePubKey(projection[0]) else null
val account = if (npub != null) {
LocalPreferences.loadFromEncryptedStorageSync(context!!, npub)
} else {
LocalPreferences.allSavedAccounts(context!!).firstNotNullOfOrNull { accountInfo ->
val localDatabase = Amber.instance.getDatabase(accountInfo.npub)
val hasAccount = localDatabase.dao().getByKeySync(packageName) != null
if (hasAccount) {
LocalPreferences.loadFromEncryptedStorageSync(context!!, accountInfo.npub)
} else {
null
val npub = if (projection != null && projection.isNotEmpty()) projection[0] else null
when (val result = SignerCore.ping(localContext, packageName, npub)) {
null -> null
is SignerCore.SignResult.Rejected -> result.toCursor()
// PING keeps its legacy two-column shape for backwards compatibility.
is SignerCore.SignResult.Reply ->
MatrixCursor(arrayOf("signature", "result")).also {
it.addRow(arrayOf<Any?>(result.result, result.result))
}
}
}
if (account == null) {
return null
}
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
val permission =
permDao
.getPermission(
packageName,
"PING",
)
val signPolicy = permDao.getSignPolicy(packageName)
val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) {
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
null,
TimeUtils.now(),
false,
),
),
account.npub,
)
}
val cursor =
MatrixCursor(arrayOf("rejected")).also {
it.addRow(arrayOf("true"))
}
return cursor
}
scope.launch {
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
null,
TimeUtils.now(),
true,
),
),
account.npub,
)
}
val cursor = MatrixCursor(arrayOf("signature", "result"))
cursor.addRow(arrayOf<Any>("pong", "pong"))
return cursor
}
else -> null
}
} catch (e: Exception) {
scope.launch {
LocalPreferences.allSavedAccounts(context!!).forEach { accInfo ->
val database = Amber.instance.getLogDatabase(accInfo.npub)
database.dao().insertLog(
LogEntity(
0,
packageName,
uriString.replace("content://$appId.", ""),
e.message ?: "Error from $callingPackage $uri",
System.currentTimeMillis(),
),
)
}
}
SignerCore.logError(localContext, packageName, uriString.replace("content://$appId.", ""), e.message ?: "Error from $callingPackage $uri")
return null
}
}
/** Maps a [SignerCore.SignResult] onto the standard cursor columns shared by most operations. */
private fun SignerCore.SignResult.toCursor(): Cursor = when (this) {
is SignerCore.SignResult.Rejected ->
MatrixCursor(arrayOf("rejected")).also { it.addRow(arrayOf("true")) }
is SignerCore.SignResult.Reply ->
MatrixCursor(arrayOf("signature", "event", "result")).also {
it.addRow(arrayOf<Any?>(signature, event, result))
}
}
override fun update(
uri: Uri,
values: ContentValues?,
@@ -0,0 +1,107 @@
package com.greenart7c3.nostrsigner
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import com.greenart7c3.nostrsigner.models.SignerType
/**
* Bound service exposing [ISignerService] — a synchronous alternative to the
* [SignerProvider] ContentProvider.
*
* Both transports share their business logic in [SignerCore]; this class only
* resolves the calling package from the binder UID and marshals
* [SignerCore.SignResult] into the [Bundle] contract documented in the AIDL.
*/
class SignerService : Service() {
private val binder = object : ISignerService.Stub() {
override fun signMessage(message: String?, npub: String?): Bundle? = run("SIGN_MESSAGE") { pkg ->
SignerCore.signMessage(applicationContext, pkg, message ?: return@run null, npub ?: return@run null)
}
override fun signEvent(eventJson: String?, npub: String?): Bundle? = run("SIGN_EVENT") { pkg ->
SignerCore.signEvent(applicationContext, pkg, eventJson ?: return@run null, npub ?: return@run null)
}
override fun nip04Encrypt(plaintext: String?, pubKey: String?, npub: String?): Bundle? = encryptOrDecrypt(SignerType.NIP04_ENCRYPT, plaintext, pubKey, npub)
override fun nip04Decrypt(ciphertext: String?, pubKey: String?, npub: String?): Bundle? = encryptOrDecrypt(SignerType.NIP04_DECRYPT, ciphertext, pubKey, npub)
override fun nip44Encrypt(plaintext: String?, pubKey: String?, npub: String?): Bundle? = encryptOrDecrypt(SignerType.NIP44_ENCRYPT, plaintext, pubKey, npub)
override fun nip44Decrypt(ciphertext: String?, pubKey: String?, npub: String?): Bundle? = encryptOrDecrypt(SignerType.NIP44_DECRYPT, ciphertext, pubKey, npub)
override fun decryptZapEvent(eventJson: String?, pubKey: String?, npub: String?): Bundle? = encryptOrDecrypt(SignerType.DECRYPT_ZAP_EVENT, eventJson, pubKey, npub)
override fun signPsbt(psbtHex: String?, npub: String?): Bundle? = run("SIGN_PSBT") { pkg ->
SignerCore.signPsbt(applicationContext, pkg, psbtHex ?: return@run null, npub ?: return@run null)
}
override fun ping(npub: String?): Bundle? = run("PING") { pkg ->
SignerCore.ping(applicationContext, pkg, npub)
}
private fun encryptOrDecrypt(
type: SignerType,
content: String?,
pubKey: String?,
npub: String?,
): Bundle? = run(type.toString()) { pkg ->
SignerCore.encryptOrDecrypt(
applicationContext,
pkg,
type,
content ?: return@run null,
pubKey ?: return@run null,
npub ?: return@run null,
)
}
}
/**
* Resolves the caller's package, runs [block], and converts the result into
* the [Bundle] contract. Returns `null` on any error or missing caller,
* matching the ContentProvider's `null` cursor behaviour.
*/
private inline fun run(
type: String,
block: (packageName: String) -> SignerCore.SignResult?,
): Bundle? {
val packageName = callingPackageName() ?: run {
Log.d(Amber.TAG, "No package name")
return null
}
return try {
block(packageName)?.toBundle()
} catch (e: Exception) {
SignerCore.logError(applicationContext, packageName, type, e.message ?: "Error from $packageName")
null
}
}
private fun callingPackageName(): String? {
val uid = Binder.getCallingUid()
return packageManager.getPackagesForUid(uid)?.firstOrNull()
}
override fun onBind(intent: Intent?): IBinder = binder
companion object {
const val KEY_REJECTED = "rejected"
const val KEY_SIGNATURE = "signature"
const val KEY_EVENT = "event"
const val KEY_RESULT = "result"
fun SignerCore.SignResult.toBundle(): Bundle = when (this) {
is SignerCore.SignResult.Rejected -> Bundle().apply { putBoolean(KEY_REJECTED, true) }
is SignerCore.SignResult.Reply -> Bundle().apply {
putString(KEY_SIGNATURE, signature)
putString(KEY_EVENT, event)
putString(KEY_RESULT, result)
}
}
}
}