From b48cad67e50c66ebd76ceb9f2d0b93eefd969827 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 16:35:00 +0000 Subject: [PATCH] feat: add Nostr signer permission system for napplets/nsites Implements per-app permission management for the internal nsec signer when webapps/napplets/nsites connect via Amethyst's built-in key: - Three trust levels on first connect (FULL_TRUST, REASONABLE, PARANOID) with a UI dialog (NappletConnectActivity) matching the design spec - Per-operation consent dialogs (NappletSignerConsentActivity) for sign-kind/encrypt/decrypt with Allow once, Don't ask again, Deny options - Per-app DataStore storage (DataStoreNostrSignerPermissionStore) using SHA-256-hashed filenames so 1000s of apps don't bloat a single file - NostrSignerPermissionLedger applies policy decisions: REASONABLE auto-allows kinds 1/6/7; FULL_TRUST auto-allows all non-payment ops - NappletBroker extended with first-connect gate and per-op signer gate, serialized by a dedicated signerConsentLock mutex - Permission management screen (NappletSignerPermissionsScreen) to review and revoke stored per-app signer permissions Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- amethyst/src/main/AndroidManifest.xml | 14 + .../DataStoreNostrSignerPermissionStore.kt | 154 +++++++++++ .../amethyst/napplet/NappletBrokerService.kt | 6 + .../napplet/NappletConnectActivity.kt | 251 ++++++++++++++++++ .../napplet/NappletConnectCoordinator.kt | 85 ++++++ .../napplet/NappletSignerConsentActivity.kt | 151 +++++++++++ .../NappletSignerConsentCoordinator.kt | 87 ++++++ .../amethyst/napplet/NostrSignerOpLabels.kt | 69 +++++ .../gateways/AccountNappletGateways.kt | 26 +- .../NappletSignerPermissionsScreen.kt | 212 +++++++++++++++ amethyst/src/main/res/values/strings.xml | 33 +++ .../amethyst/commons/napplet/NappletBroker.kt | 103 +++++++ .../napplet/signers/AppSignerPolicy.kt | 40 +++ .../napplet/signers/NostrOpDecision.kt | 36 +++ .../signers/NostrSignerConsentPrompt.kt | 105 ++++++++ .../commons/napplet/signers/NostrSignerOp.kt | 71 +++++ .../signers/NostrSignerPermissionLedger.kt | 91 +++++++ .../signers/NostrSignerPermissionStore.kt | 130 +++++++++ 18 files changed, 1663 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 242aa6698e..72bdeefe46 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -431,6 +431,20 @@ android:excludeFromRecents="true" android:launchMode="singleTop" android:theme="@android:style/Theme.Translucent.NoTitleBar" /> + + + + >() + + private fun storeFor(coordinate: String): DataStore = + cache.getOrCreate(coordinate) { + PreferenceDataStoreFactory.create( + produceFile = { File(filesDir, "datastore/nsp_${hash(coordinate)}.preferences_pb") }, + ) + } + + override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? { + val raw = storeFor(coordinate).data.first()[KEY_POLICY] ?: return null + return runCatching { AppSignerPolicy.valueOf(raw) }.getOrNull() + } + + override suspend fun storePolicy( + coordinate: String, + policy: AppSignerPolicy, + ) { + storeFor(coordinate).edit { + it[KEY_COORDINATE] = coordinate + it[KEY_POLICY] = policy.name + } + } + + override suspend fun clearPolicy(coordinate: String) { + storeFor(coordinate).edit { it.remove(KEY_POLICY) } + } + + override suspend fun loadOpDecision( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision? { + val raw = storeFor(coordinate).data.first()[opKey(op)] ?: return null + return runCatching { NostrOpDecision.valueOf(raw) }.getOrNull() + } + + override suspend fun storeOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) { + storeFor(coordinate).edit { + it[KEY_COORDINATE] = coordinate + it[opKey(op)] = decision.name + } + } + + override suspend fun clearOpDecision( + coordinate: String, + op: NostrSignerOp, + ) { + storeFor(coordinate).edit { it.remove(opKey(op)) } + } + + override suspend fun allPolicies(): Map { + val dir = File(filesDir, "datastore") + if (!dir.exists()) return emptyMap() + val result = mutableMapOf() + for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) { + val coordinate = + file.nameWithoutExtension.let { name -> + // Derive the DataStore by re-opening the file; we stored the coordinate inside. + val ds = + cache.getOrCreate(name) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + ds.data.first()[KEY_COORDINATE] + } ?: continue + val policy = loadPolicy(coordinate) ?: continue + result[coordinate] = policy + } + return result + } + + override suspend fun allOpDecisions(coordinate: String): Map { + val prefs = storeFor(coordinate).data.first() + val result = mutableMapOf() + for ((key, value) in prefs.asMap()) { + val name = key.name + if (!name.startsWith(OP_PREFIX)) continue + val opKey = name.removePrefix(OP_PREFIX) + val decision = runCatching { NostrOpDecision.valueOf(value as String) }.getOrNull() ?: continue + result[opKey] = decision + } + return result + } + + override suspend fun clearAll(coordinate: String) { + storeFor(coordinate).edit { it.clear() } + } + + private fun opKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}") + + companion object { + private val KEY_COORDINATE = stringPreferencesKey("coordinate") + private val KEY_POLICY = stringPreferencesKey("policy") + private const val OP_PREFIX = "op:" + + private fun hash(coordinate: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray()) + return digest.take(8).joinToString("") { "%02x".format(it) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 06bc236b3f..eeaa67157f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry @@ -77,6 +78,10 @@ class NappletBrokerService : Service() { // One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM. private val ledger by lazy { NappletPermissionLedger(DataStoreNappletPermissionStore(applicationContext)) } + // Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only + // instantiated in the main process where the signer lives; never touched from :napplet. + private val signerLedger by lazy { NostrSignerPermissionLedger(DataStoreNostrSignerPermissionStore(applicationContext)) } + // Per-applet sandboxed key-value store (namespaced by coordinate inside the impl). private val storage by lazy { DataStoreNappletStorage(applicationContext) } @@ -323,6 +328,7 @@ class NappletBrokerService : Service() { // Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes // through Tor when asked + active, and falls back to clearnet otherwise. httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) }, + signerLedger = signerLedger, ).broker() cachedBroker = account to broker return broker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt new file mode 100644 index 0000000000..f14797e10e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt @@ -0,0 +1,251 @@ +/* + * 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.amethyst.napplet + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme + +class NappletConnectActivity : ComponentActivity() { + private var token: String? = null + private var decided = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val token = intent.getStringExtra(NappletConnectCoordinator.EXTRA_TOKEN) + this.token = token + val info = token?.let { NappletConnectCoordinator.infoFor(it) } + if (token == null || info == null) { + finish() + return + } + + setContent { + AmethystTheme { + NappletConnectScreen( + info = info, + onConnect = { policy -> + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy)) + finish() + }, + onBlock = { + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Blocked) + finish() + }, + onCancel = { + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled) + finish() + }, + ) + } + } + } + + override fun finish() { + if (!decided) token?.let { NappletConnectCoordinator.cancel(it) } + super.finish() + } +} + +@Composable +private fun NappletConnectScreen( + info: NappletConnectInfo, + onConnect: (AppSignerPolicy) -> Unit, + onBlock: () -> Unit, + onCancel: () -> Unit, +) { + var selected by remember { mutableStateOf(AppSignerPolicy.REASONABLE) } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background.copy(alpha = 0.85f), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(32.dp)) + Text( + stringResource(R.string.napplet_connect_title), + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(16.dp)) + + // App card + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + modifier = Modifier.size(40.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + ) {} + Column { + Text(info.appletTitle, style = MaterialTheme.typography.titleMedium) + Text(info.domain, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + + Spacer(Modifier.height(24.dp)) + Text( + stringResource(R.string.napplet_connect_how_handle), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + + // Trust level options + PolicyOption( + selected = selected == AppSignerPolicy.FULL_TRUST, + icon = "❤", + label = stringResource(R.string.napplet_policy_full_trust), + description = stringResource(R.string.napplet_policy_full_trust_desc), + onClick = { selected = AppSignerPolicy.FULL_TRUST }, + ) + Spacer(Modifier.height(8.dp)) + PolicyOption( + selected = selected == AppSignerPolicy.REASONABLE, + icon = "👍", + label = stringResource(R.string.napplet_policy_reasonable), + description = stringResource(R.string.napplet_policy_reasonable_desc), + onClick = { selected = AppSignerPolicy.REASONABLE }, + ) + Spacer(Modifier.height(8.dp)) + PolicyOption( + selected = selected == AppSignerPolicy.PARANOID, + icon = "🕶", + label = stringResource(R.string.napplet_policy_paranoid), + description = stringResource(R.string.napplet_policy_paranoid_desc), + onClick = { selected = AppSignerPolicy.PARANOID }, + ) + + Spacer(Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.cancel)) + } + Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.napplet_connect_button)) + } + } + + Spacer(Modifier.height(16.dp)) + TextButton(onClick = onBlock) { + Text( + stringResource(R.string.napplet_connect_block, info.domain), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +private fun PolicyOption( + selected: Boolean, + icon: String, + label: String, + description: String, + onClick: () -> Unit, +) { + val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface + + Surface( + modifier = + Modifier + .fillMaxWidth() + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = bgColor, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(icon, style = MaterialTheme.typography.headlineSmall) + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.titleSmall) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (selected) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt new file mode 100644 index 0000000000..0f8f244526 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt @@ -0,0 +1,85 @@ +/* + * 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.amethyst.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Everything the "Connect to Nostr" dialog needs to render. */ +data class NappletConnectInfo( + val appletTitle: String, + val coordinate: String, + val domain: String, +) + +/** + * Bridges the broker to the "Connect to Nostr" first-connect UI. Suspends in [requestConnect]; + * the Activity resolves the deferred with the user's choice. + * A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant. + */ +object NappletConnectCoordinator { + private class Pending( + val info: NappletConnectInfo, + val deferred: CompletableDeferred, + ) + + private val pending = ConcurrentHashMap() + + suspend fun requestConnect( + context: Context, + info: NappletConnectInfo, + ): AppConnectResult { + val token = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[token] = Pending(info, deferred) + + context.startActivity( + Intent(context, NappletConnectActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token), + ) + + return try { + deferred.await() + } finally { + pending.remove(token) + } + } + + fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info + + fun complete( + token: String, + result: AppConnectResult, + ) { + pending[token]?.deferred?.complete(result) + } + + fun cancel(token: String) { + pending[token]?.deferred?.complete(AppConnectResult.Cancelled) + } + + const val EXTRA_TOKEN = "napplet_connect_token" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt new file mode 100644 index 0000000000..2a01c87fa2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -0,0 +1,151 @@ +/* + * 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.amethyst.napplet + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme + +class NappletSignerConsentActivity : ComponentActivity() { + private var token: String? = null + private var decided = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val token = intent.getStringExtra(NappletSignerConsentCoordinator.EXTRA_TOKEN) + this.token = token + val info = token?.let { NappletSignerConsentCoordinator.infoFor(it) } + if (token == null || info == null) { + finish() + return + } + + setContent { + AmethystTheme { + NappletSignerConsentDialog( + info = info, + onGrant = { grant -> + decided = true + NappletSignerConsentCoordinator.complete(token, grant) + finish() + }, + onDismiss = { + decided = true + NappletSignerConsentCoordinator.cancel(token) + finish() + }, + ) + } + } + } + + override fun finish() { + if (!decided) token?.let { NappletSignerConsentCoordinator.cancel(it) } + super.finish() + } +} + +@Composable +private fun NappletSignerConsentDialog( + info: NappletSignerConsentInfo, + onGrant: (SignerOpGrant) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(info.appletTitle) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(info.operationSummary) + if (info.contentPreview.isNotBlank()) { + Text( + "“${info.contentPreview}”", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + info.coordinate, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Column(modifier = Modifier.fillMaxWidth()) { + TextButton( + onClick = { onGrant(SignerOpGrant.AllowOnce) }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) { Text(stringResource(R.string.napplet_signer_allow_once)) } + TextButton( + onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) { Text(stringResource(R.string.napplet_signer_allow_op, info.operationSummary)) } + TextButton( + onClick = { onGrant(SignerOpGrant.AllowAll) }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) { Text(stringResource(R.string.napplet_signer_allow_all)) } + } + }, + dismissButton = { + Column(modifier = Modifier.fillMaxWidth()) { + TextButton( + onClick = { onGrant(SignerOpGrant.DenyOnce) }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) { Text(stringResource(R.string.napplet_signer_deny_once)) } + TextButton( + onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) { Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary)) } + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt new file mode 100644 index 0000000000..13eb58bc5d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt @@ -0,0 +1,87 @@ +/* + * 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.amethyst.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Everything the per-operation consent dialog needs to render. */ +data class NappletSignerConsentInfo( + val appletTitle: String, + val coordinate: String, + val op: NostrSignerOp, + val operationSummary: String, + val contentPreview: String, +) + +/** + * Bridges the broker to the per-operation signer consent UI. + * A dismissed dialog resolves to [SignerOpGrant.DenyOnce] — fails closed. + */ +object NappletSignerConsentCoordinator { + private class Pending( + val info: NappletSignerConsentInfo, + val deferred: CompletableDeferred, + ) + + private val pending = ConcurrentHashMap() + + suspend fun requestConsent( + context: Context, + info: NappletSignerConsentInfo, + ): SignerOpGrant { + val token = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[token] = Pending(info, deferred) + + context.startActivity( + Intent(context, NappletSignerConsentActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token), + ) + + return try { + deferred.await() + } finally { + pending.remove(token) + } + } + + fun infoFor(token: String): NappletSignerConsentInfo? = pending[token]?.info + + fun complete( + token: String, + grant: SignerOpGrant, + ) { + pending[token]?.deferred?.complete(grant) + } + + fun cancel(token: String) { + pending[token]?.deferred?.complete(SignerOpGrant.DenyOnce) + } + + const val EXTRA_TOKEN = "napplet_signer_consent_token" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt new file mode 100644 index 0000000000..84f6773b93 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -0,0 +1,69 @@ +/* + * 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.amethyst.napplet + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp + +/** Human-readable label for a [NostrSignerOp]. */ +fun NostrSignerOp.label(context: Context): String = + when (this) { + is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind, kind) + NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt) + NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt) + } + +/** Builds the [NappletSignerConsentInfo] needed by the per-op consent dialog. */ +fun buildSignerConsentInfo( + context: Context, + identity: NappletIdentity, + op: NostrSignerOp, + request: NappletRequest, +): NappletSignerConsentInfo { + val title = identity.identifier.ifBlank { context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) } + val summary = op.label(context) + val preview = + when (request) { + is NappletRequest.Publish -> request.content.take(160).trim() + is NappletRequest.SignEvent -> request.content.take(160).trim() + else -> "" + } + return NappletSignerConsentInfo( + appletTitle = title, + coordinate = identity.coordinate, + op = op, + operationSummary = summary, + contentPreview = preview, + ) +} + +/** Creates a [NappletConnectInfo] for the first-connect dialog. */ +fun buildConnectInfo( + context: Context, + identity: NappletIdentity, +): NappletConnectInfo { + val title = identity.identifier.ifBlank { context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) } + val domain = identity.coordinate.substringBefore(":") + return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index cee3ccde5e..89bf3ecf31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -41,10 +41,17 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.napplet.NappletConnectCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentSummary import com.vitorpamplona.amethyst.napplet.NappletNotificationStore +import com.vitorpamplona.amethyst.napplet.NappletSignerConsentCoordinator +import com.vitorpamplona.amethyst.napplet.buildConnectInfo +import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -72,6 +79,7 @@ class AccountNappletGateways( private val ledger: NappletPermissionLedger, private val storage: NappletStorage, private val httpClient: (useProxy: Boolean) -> OkHttpClient, + private val signerLedger: NostrSignerPermissionLedger? = null, ) { private val consentSummary = NappletConsentSummary(context) @@ -129,7 +137,23 @@ class AccountNappletGateways( } } - return NappletBroker(account.signer, ledger, consent, relay, storage, wallet, resource, upload = upload, identityReads = identityReads, theme = theme, notify = notify) + val connectPrompt = + NostrConnectPrompt { identity -> + NappletConnectCoordinator.requestConnect( + context = context, + info = buildConnectInfo(context, identity), + ) + } + + val signerConsent = + NostrSignerConsentPrompt { identity, op, request -> + NappletSignerConsentCoordinator.requestConsent( + context = context, + info = buildSignerConsentInfo(context, identity, op, request), + ) + } + + return NappletBroker(account.signer, ledger, consent, signerLedger = signerLedger, nostrConnectPrompt = connectPrompt, signerConsentPrompt = signerConsent, relay = relay, storage = storage, wallet = wallet, resource = resource, upload = upload, identityReads = identityReads, theme = theme, notify = notify) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt new file mode 100644 index 0000000000..03086d3f27 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt @@ -0,0 +1,212 @@ +/* + * 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.amethyst.ui.screen.loggedIn.napplets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class AppSignerPermissionEntry( + val coordinate: String, + val policy: AppSignerPolicy, + val opOverrides: Map, +) + +class NappletSignerPermissionsViewModel( + private val ledger: NostrSignerPermissionLedger, +) : ViewModel() { + private val _entries = MutableStateFlow>(emptyList()) + val entries: StateFlow> = _entries.asStateFlow() + + init { + reload() + } + + fun reload() { + viewModelScope.launch { + val policies = ledger.store.allPolicies() + _entries.value = + policies.map { (coord, policy) -> + AppSignerPermissionEntry( + coordinate = coord, + policy = policy, + opOverrides = ledger.store.allOpDecisions(coord), + ) + } + } + } + + fun revokeAll(coordinate: String) { + viewModelScope.launch { + ledger.revokeAll(coordinate) + reload() + } + } + + fun revokeOp( + coordinate: String, + opKey: String, + ) { + viewModelScope.launch { + NostrSignerOp.fromKey(opKey)?.let { ledger.revokeOpDecision(coordinate, it) } + reload() + } + } +} + +@Composable +fun NappletSignerPermissionsScreen(viewModel: NappletSignerPermissionsViewModel) { + val entries by viewModel.entries.collectAsState() + + if (entries.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.napplet_signer_permissions_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(entries, key = { it.coordinate }) { entry -> + AppSignerPermissionCard( + entry = entry, + onRevokeAll = { viewModel.revokeAll(entry.coordinate) }, + onRevokeOp = { opKey -> viewModel.revokeOp(entry.coordinate, opKey) }, + ) + } + } + } +} + +@Composable +private fun AppSignerPermissionCard( + entry: AppSignerPermissionEntry, + onRevokeAll: () -> Unit, + onRevokeOp: (String) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + entry.coordinate.substringAfter(":").ifBlank { entry.coordinate }, + style = MaterialTheme.typography.titleSmall, + ) + Text( + entry.coordinate, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onRevokeAll) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringResource(R.string.napplet_signer_permissions_revoke_all), + ) + } + } + + SuggestionChip( + onClick = {}, + label = { Text(entry.policy.label()) }, + ) + + if (entry.opOverrides.isNotEmpty()) { + HorizontalDivider() + Text( + stringResource(R.string.napplet_permissions_overrides), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + for ((opKey, decision) in entry.opOverrides) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(opKey, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + Text( + decision.name, + style = MaterialTheme.typography.labelSmall, + color = if (decision == NostrOpDecision.DENY) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + IconButton(onClick = { onRevokeOp(opKey) }, modifier = Modifier.size(32.dp)) { + Icon(symbol = MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + } + } + } + } + } + } +} + +@Composable +private fun AppSignerPolicy.label(): String = + when (this) { + AppSignerPolicy.FULL_TRUST -> stringResource(R.string.napplet_policy_full_trust) + AppSignerPolicy.REASONABLE -> stringResource(R.string.napplet_policy_reasonable) + AppSignerPolicy.PARANOID -> stringResource(R.string.napplet_policy_paranoid) + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fde413a38a..66d53ea25a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -752,6 +752,39 @@ This nApplet wants to pay a Lightning invoice for %1$d sat. This nApplet wants to pay a Lightning invoice for %1$d sats. + + Connect to Nostr + How should this app\'s requests be handled? + Connect + Block and ignore %1$s + + + I fully trust it + Auto-sign all requests (except payments) + Let\'s be reasonable + Auto-approve most common requests + I\'m a bit paranoid + Do not sign anything without asking me! + + + Allow once + Don\'t ask again to %1$s + Don\'t ask again for any Nostr requests + Deny + Always deny %1$s + + + sign kind %1$d event + encrypt a message + decrypt a message + + + Connected Apps + Revoke all permissions + Operation overrides + No apps have connected yet. + Revoke all permissions + Source: %1$s v%1$s Download diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 0d035a4ad2..85188cfbe2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -25,6 +25,15 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant +import com.vitorpamplona.amethyst.commons.napplet.signers.toSignerOp import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -71,12 +80,19 @@ class NappletBroker( private val identityReads: NappletIdentityGateway? = null, private val theme: NappletThemeGateway? = null, private val notify: NappletNotifyGateway? = null, + private val signerLedger: NostrSignerPermissionLedger? = null, + private val nostrConnectPrompt: NostrConnectPrompt? = null, + private val signerConsentPrompt: NostrSignerConsentPrompt? = null, ) { // Serializes the consent-prompt path so concurrent requests queue into one dialog at a time // (see [authorizeWithConsent]). Only the prompt is held here; non-prompting paths and execute() // run unserialized. private val consentLock = Mutex() + // Serializes first-connect and per-op signer consent dialogs so concurrent signing requests + // queue into one dialog at a time rather than launching several dialogs simultaneously. + private val signerConsentLock = Mutex() + /** * Authorizes and runs [request] on behalf of [identity]. [declared] is the capability set the * manifest's `requires` resolved to; a request outside it is refused before any prompt. @@ -105,6 +121,13 @@ class NappletBroker( return NappletResponse.Denied(capability, "Blocked by a standing denial.") } + // For internal signers, show the first-connect dialog if the app has no signer policy yet. + if (signer is NostrSignerInternal && signerLedger != null && !signerLedger.hasPolicy(identity.coordinate)) { + if (!ensureConnected(identity, declared)) { + return NappletResponse.Denied(capability, "Connection not authorized.") + } + } + val authorized = when { // Keyboard/command action registration is a shell-mediated UI affordance, not key @@ -121,6 +144,14 @@ class NappletBroker( if (!authorized) return NappletResponse.Denied(capability, "The user declined.") + // Additional per-operation gate for internal signer signing/encryption. + if (signer is NostrSignerInternal && signerLedger != null) { + val op = request.toSignerOp() + if (op != null && !authorizeSignerOp(identity, op, request)) { + return NappletResponse.Denied(capability, "Signing operation declined.") + } + } + return try { execute(identity, request) } catch (e: CancellationException) { @@ -310,4 +341,76 @@ class NappletBroker( } else { tags + arrayOf(arrayOf("p", recipient)) } + + /** + * Shows the first-connect "Connect to Nostr" dialog if no signer policy exists yet. + * On success, stores the chosen policy and bulk-grants all declared non-payment capabilities. + * Returns false if the user cancelled or blocked the app. + */ + private suspend fun ensureConnected( + identity: NappletIdentity, + declared: Set, + ): Boolean = + signerConsentLock.withLock { + val sl = signerLedger ?: return@withLock true + // Re-check after acquiring lock: a sibling request may have set the policy while we waited. + if (sl.hasPolicy(identity.coordinate)) return@withLock true + + val prompt = nostrConnectPrompt ?: return@withLock true + when (val result = prompt.request(identity)) { + is AppConnectResult.Connected -> { + sl.setPolicy(identity.coordinate, result.policy) + // Bulk-grant all declared capabilities except payments so the app works immediately. + for (cap in declared) { + if (!cap.requiresPerUseConsent) { + ledger.record(identity, cap, GrantState.ALLOW_ALWAYS) + } + } + true + } + AppConnectResult.Blocked -> { + sl.setPolicy(identity.coordinate, AppSignerPolicy.PARANOID) + for (cap in declared) { + ledger.record(identity, cap, GrantState.DENY) + } + false + } + AppConnectResult.Cancelled -> false + } + } + + /** + * Gates a specific signing/encryption operation through the signer permission ledger. + * If the ledger says ASK, prompts the user and records their choice. + */ + private suspend fun authorizeSignerOp( + identity: NappletIdentity, + op: NostrSignerOp, + request: NappletRequest, + ): Boolean = + signerConsentLock.withLock { + val sl = signerLedger ?: return@withLock true + when (sl.decide(identity.coordinate, op)) { + NostrOpDecision.ALLOW -> true + NostrOpDecision.DENY -> false + NostrOpDecision.ASK -> { + val prompt = signerConsentPrompt ?: return@withLock true + when (val grant = prompt.request(identity, op, request)) { + is SignerOpGrant.AllowAll -> { + sl.setPolicy(identity.coordinate, AppSignerPolicy.FULL_TRUST) + true + } + is SignerOpGrant.AllowForOp -> { + sl.setOpDecision(identity.coordinate, op, NostrOpDecision.ALLOW) + true + } + is SignerOpGrant.DenyForOp -> { + sl.setOpDecision(identity.coordinate, op, NostrOpDecision.DENY) + false + } + else -> grant.isAllowed + } + } + } + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt new file mode 100644 index 0000000000..bfd36e6af5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt @@ -0,0 +1,40 @@ +/* + * 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.amethyst.commons.napplet.signers + +/** + * The user's top-level trust decision for one app's access to the internal Nostr signer. + * Set once on first connection; governs all future signing/encryption operations unless + * overridden by a per-operation [NostrOpDecision] in the [NostrSignerPermissionLedger]. + */ +enum class AppSignerPolicy { + /** Auto-approve all signing and encryption operations (except payments, which always prompt). */ + FULL_TRUST, + + /** + * Auto-approve the most common operations (kind 1 short notes, kind 6 reposts, kind 7 + * reactions); ask before anything else. A reasonable default for most apps. + */ + REASONABLE, + + /** Prompt before every single signing or encryption operation. */ + PARANOID, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt new file mode 100644 index 0000000000..a6cbda8f6b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt @@ -0,0 +1,36 @@ +/* + * 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.amethyst.commons.napplet.signers + +/** + * A per-operation standing decision stored in [NostrSignerPermissionStore]. + * Overrides [AppSignerPolicy] for the specific [NostrSignerOp] it is keyed to. + */ +enum class NostrOpDecision { + /** Automatically allow this operation without prompting. */ + ALLOW, + + /** Prompt the user on each request (default behavior). */ + ASK, + + /** Always deny this operation without prompting. */ + DENY, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt new file mode 100644 index 0000000000..f991342c7b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt @@ -0,0 +1,105 @@ +/* + * 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.amethyst.commons.napplet.signers + +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest + +// --------------------------------------------------------------------------- +// First-connect dialog +// --------------------------------------------------------------------------- + +/** The user's response to the "Connect to Nostr" first-connection dialog. */ +sealed interface AppConnectResult { + /** The user accepted and chose a trust level. */ + data class Connected( + val policy: AppSignerPolicy, + ) : AppConnectResult + + /** The user chose to block this app permanently. */ + data object Blocked : AppConnectResult + + /** The user dismissed the dialog without making a choice. */ + data object Cancelled : AppConnectResult +} + +/** + * Shows the "Connect to Nostr" first-connection dialog for [identity] and suspends + * until the user makes a choice. The result drives the [AppSignerPolicy] stored in + * [NostrSignerPermissionLedger] and the bulk capability grant in [NappletBroker][com.vitorpamplona.amethyst.commons.napplet.NappletBroker]. + */ +fun interface NostrConnectPrompt { + suspend fun request(identity: NappletIdentity): AppConnectResult +} + +// --------------------------------------------------------------------------- +// Per-operation consent dialog +// --------------------------------------------------------------------------- + +/** + * The user's response to a per-signing-operation consent dialog. + * The broker records any "remember" variant before returning [isAllowed]. + */ +sealed interface SignerOpGrant { + /** Whether the in-flight request may proceed. */ + val isAllowed: Boolean + + /** Allow this one request; prompt again next time. */ + data object AllowOnce : SignerOpGrant { + override val isAllowed = true + } + + /** Allow and remember: don't ask again for [op]. */ + data class AllowForOp( + val op: NostrSignerOp, + ) : SignerOpGrant { + override val isAllowed = true + } + + /** Allow and upgrade to [AppSignerPolicy.FULL_TRUST] for all future requests. */ + data object AllowAll : SignerOpGrant { + override val isAllowed = true + } + + /** Deny this one request; prompt again next time. */ + data object DenyOnce : SignerOpGrant { + override val isAllowed = false + } + + /** Deny and remember: always deny [op]. */ + data class DenyForOp( + val op: NostrSignerOp, + ) : SignerOpGrant { + override val isAllowed = false + } +} + +/** + * Prompts the user to authorize (or deny) a specific Nostr operation for [identity]. + * Suspends until the user answers. [DenyOnce] is the safe default when dismissed. + */ +fun interface NostrSignerConsentPrompt { + suspend fun request( + identity: NappletIdentity, + op: NostrSignerOp, + request: NappletRequest, + ): SignerOpGrant +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt new file mode 100644 index 0000000000..bdb31e981f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt @@ -0,0 +1,71 @@ +/* + * 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.amethyst.commons.napplet.signers + +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest + +/** + * A Nostr-specific cryptographic operation that requires the internal signer. + * Used to gate signing and encryption independently, per app. + */ +sealed interface NostrSignerOp { + /** Sign (and optionally publish) an event of the given [kind]. */ + data class SignKind( + val kind: Int, + ) : NostrSignerOp + + /** Encrypt a message (NIP-04 or NIP-44). */ + data object Encrypt : NostrSignerOp + + /** Decrypt a message (NIP-04 or NIP-44). */ + data object Decrypt : NostrSignerOp + + /** Stable storage key for this operation, used as a DataStore key fragment. */ + val key: String + get() = + when (this) { + is SignKind -> "sign:$kind" + Encrypt -> "encrypt" + Decrypt -> "decrypt" + } + + companion object { + fun fromKey(key: String): NostrSignerOp? = + when { + key == "encrypt" -> Encrypt + key == "decrypt" -> Decrypt + key.startsWith("sign:") -> key.removePrefix("sign:").toIntOrNull()?.let { SignKind(it) } + else -> null + } + } +} + +/** + * Maps a [NappletRequest] to the [NostrSignerOp] it represents, or `null` if the request + * does not involve signing or encryption. + */ +fun NappletRequest.toSignerOp(): NostrSignerOp? = + when (this) { + is NappletRequest.Publish -> NostrSignerOp.SignKind(kind) + is NappletRequest.SignEvent -> NostrSignerOp.SignKind(kind) + is NappletRequest.PublishEncrypted -> NostrSignerOp.Encrypt + else -> null + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt new file mode 100644 index 0000000000..e2e7b6812d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt @@ -0,0 +1,91 @@ +/* + * 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.amethyst.commons.napplet.signers + +/** + * The per-app Nostr signer permission ledger. Decides whether a signing or encryption + * operation should auto-allow, auto-deny, or ask the user, by consulting: + * + * 1. Per-operation overrides ([NostrSignerPermissionStore.loadOpDecision]) — these always win. + * 2. The app's [AppSignerPolicy] trust level ([NostrSignerPermissionStore.loadPolicy]). + * 3. The built-in "reasonable" set (kind 1/6/7 are auto-allowed) when policy is [AppSignerPolicy.REASONABLE]. + * + * When no policy has been set (`null`), [decide] returns [NostrOpDecision.ASK], which triggers the + * first-connect dialog in the broker. + */ +class NostrSignerPermissionLedger( + val store: NostrSignerPermissionStore, +) { + /** + * `true` when a trust level has been set for [coordinate] — i.e. the "Connect to Nostr" + * dialog has already been shown and the user made a choice. + */ + suspend fun hasPolicy(coordinate: String): Boolean = store.loadPolicy(coordinate) != null + + /** The authorization verdict for ([coordinate], [op]) based on stored policy + per-op overrides. */ + suspend fun decide( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision { + store.loadOpDecision(coordinate, op)?.let { return it } + return when (store.loadPolicy(coordinate)) { + AppSignerPolicy.FULL_TRUST -> NostrOpDecision.ALLOW + AppSignerPolicy.PARANOID -> NostrOpDecision.ASK + AppSignerPolicy.REASONABLE -> reasonableDecision(op) + null -> NostrOpDecision.ASK + } + } + + /** Stores the user's chosen trust level for [coordinate]. */ + suspend fun setPolicy( + coordinate: String, + policy: AppSignerPolicy, + ) = store.storePolicy(coordinate, policy) + + /** Stores a per-operation override for ([coordinate], [op]). */ + suspend fun setOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) = store.storeOpDecision(coordinate, op, decision) + + /** Removes a per-operation override, reverting to the policy-level decision. */ + suspend fun revokeOpDecision( + coordinate: String, + op: NostrSignerOp, + ) = store.clearOpDecision(coordinate, op) + + /** Removes all signer permissions for [coordinate] — trust level and all per-op overrides. */ + suspend fun revokeAll(coordinate: String) = store.clearAll(coordinate) + + private fun reasonableDecision(op: NostrSignerOp): NostrOpDecision = + when (op) { + is NostrSignerOp.SignKind -> + when (op.kind) { + 1 -> NostrOpDecision.ALLOW // Short text notes: common, low-risk + 6 -> NostrOpDecision.ALLOW // Reposts + 7 -> NostrOpDecision.ALLOW // Reactions / emoji + else -> NostrOpDecision.ASK + } + NostrSignerOp.Encrypt -> NostrOpDecision.ASK + NostrSignerOp.Decrypt -> NostrOpDecision.ASK + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt new file mode 100644 index 0000000000..f409183ebe --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt @@ -0,0 +1,130 @@ +/* + * 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.amethyst.commons.napplet.signers + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock + +/** + * Persistence for the per-app internal-signer permissions: each app's [AppSignerPolicy] + * trust level and any [NostrOpDecision] per-operation overrides. Keyed by napplet + * coordinate (e.g. `":"`). + * + * The Android implementation uses one DataStore file per coordinate so loading one app's + * permissions never reads another app's data — essential at scale (1000s of apps). + * Tests use [InMemoryNostrSignerPermissionStore]. + */ +interface NostrSignerPermissionStore { + /** The stored trust level for [coordinate], or `null` if no policy has been set yet. */ + suspend fun loadPolicy(coordinate: String): AppSignerPolicy? + + /** Persist [policy] as the trust level for [coordinate]. */ + suspend fun storePolicy( + coordinate: String, + policy: AppSignerPolicy, + ) + + /** Remove the stored trust level for [coordinate]. */ + suspend fun clearPolicy(coordinate: String) + + /** The stored per-operation decision for ([coordinate], [op]), or `null` if none set. */ + suspend fun loadOpDecision( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision? + + /** Persist [decision] for ([coordinate], [op]). */ + suspend fun storeOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) + + /** Remove the per-operation decision for ([coordinate], [op]). */ + suspend fun clearOpDecision( + coordinate: String, + op: NostrSignerOp, + ) + + /** All stored trust levels, keyed by coordinate — for the permissions-management screen. */ + suspend fun allPolicies(): Map + + /** + * All per-operation overrides for [coordinate], keyed by [NostrSignerOp.key] — for the + * permissions-management screen. + */ + suspend fun allOpDecisions(coordinate: String): Map + + /** Remove all signer permissions (policy + all op overrides) for [coordinate]. */ + suspend fun clearAll(coordinate: String) +} + +/** A thread-safe in-memory [NostrSignerPermissionStore] for tests and ephemeral sessions. */ +class InMemoryNostrSignerPermissionStore : NostrSignerPermissionStore { + private val lock = KmpLock() + private val policies = mutableMapOf() + private val opDecisions = mutableMapOf>() + + override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? = lock.withLock { policies[coordinate] } + + override suspend fun storePolicy( + coordinate: String, + policy: AppSignerPolicy, + ) = lock.withLock { policies[coordinate] = policy } + + override suspend fun clearPolicy(coordinate: String) = + lock.withLock { + policies.remove(coordinate) + Unit + } + + override suspend fun loadOpDecision( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision? = lock.withLock { opDecisions[coordinate]?.get(op.key) } + + override suspend fun storeOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) = lock.withLock { + opDecisions.getOrPut(coordinate) { mutableMapOf() }[op.key] = decision + } + + override suspend fun clearOpDecision( + coordinate: String, + op: NostrSignerOp, + ) = lock.withLock { + opDecisions[coordinate]?.remove(op.key) + Unit + } + + override suspend fun allPolicies(): Map = lock.withLock { policies.toMap() } + + override suspend fun allOpDecisions(coordinate: String): Map = lock.withLock { opDecisions[coordinate]?.toMap() ?: emptyMap() } + + override suspend fun clearAll(coordinate: String) = + lock.withLock { + policies.remove(coordinate) + opDecisions.remove(coordinate) + Unit + } +}