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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
This commit is contained in:
Claude
2026-06-27 22:58:50 +00:00
parent 82f40e0ed1
commit b48cad67e5
18 changed files with 1663 additions and 1 deletions
+14
View File
@@ -431,6 +431,20 @@
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- First-connect "Connect to Nostr" dialog. -->
<activity
android:name=".napplet.NappletConnectActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Per-operation signer consent dialog. -->
<activity
android:name=".napplet.NappletSignerConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Main-process broker: holds the signer and brokers capabilities for the sandbox. -->
<service
@@ -0,0 +1,154 @@
/*
* 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 androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
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.NostrSignerPermissionStore
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.first
import java.io.File
import java.security.MessageDigest
/**
* Per-coordinate DataStore-backed [NostrSignerPermissionStore]. One small `.preferences_pb`
* file per app (keyed by a SHA-256 prefix of the coordinate) so loading or saving one app's
* permissions never touches another app's data — essential at scale with 1000s of apps.
*
* The coordinate is stored inside each file under [KEY_COORDINATE] so [allPolicies] can
* reverse-map file → coordinate without scanning the filesystem.
*/
class DataStoreNostrSignerPermissionStore(
private val filesDir: File,
) : NostrSignerPermissionStore {
constructor(context: Context) : this(context.applicationContext.filesDir)
private val cache = LargeCache<String, DataStore<Preferences>>()
private fun storeFor(coordinate: String): DataStore<Preferences> =
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<String, AppSignerPolicy> {
val dir = File(filesDir, "datastore")
if (!dir.exists()) return emptyMap()
val result = mutableMapOf<String, AppSignerPolicy>()
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<String, NostrOpDecision> {
val prefs = storeFor(coordinate).data.first()
val result = mutableMapOf<String, NostrOpDecision>()
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) }
}
}
}
@@ -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
@@ -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,
)
}
}
}
}
@@ -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<AppConnectResult>,
)
private val pending = ConcurrentHashMap<String, Pending>()
suspend fun requestConnect(
context: Context,
info: NappletConnectInfo,
): AppConnectResult {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<AppConnectResult>()
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"
}
@@ -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)) }
}
},
)
}
@@ -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<SignerOpGrant>,
)
private val pending = ConcurrentHashMap<String, Pending>()
suspend fun requestConsent(
context: Context,
info: NappletSignerConsentInfo,
): SignerOpGrant {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<SignerOpGrant>()
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"
}
@@ -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)
}
@@ -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)
}
/**
@@ -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<String, NostrOpDecision>,
)
class NappletSignerPermissionsViewModel(
private val ledger: NostrSignerPermissionLedger,
) : ViewModel() {
private val _entries = MutableStateFlow<List<AppSignerPermissionEntry>>(emptyList())
val entries: StateFlow<List<AppSignerPermissionEntry>> = _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)
}
+33
View File
@@ -752,6 +752,39 @@
<item quantity="one">This nApplet wants to pay a Lightning invoice for %1$d sat.</item>
<item quantity="other">This nApplet wants to pay a Lightning invoice for %1$d sats.</item>
</plurals>
<!-- Signer permissions: first-connect dialog -->
<string name="napplet_connect_title">Connect to Nostr</string>
<string name="napplet_connect_how_handle">How should this app\'s requests be handled?</string>
<string name="napplet_connect_button">Connect</string>
<string name="napplet_connect_block">Block and ignore %1$s</string>
<!-- Signer trust levels -->
<string name="napplet_policy_full_trust">I fully trust it</string>
<string name="napplet_policy_full_trust_desc">Auto-sign all requests (except payments)</string>
<string name="napplet_policy_reasonable">Let\'s be reasonable</string>
<string name="napplet_policy_reasonable_desc">Auto-approve most common requests</string>
<string name="napplet_policy_paranoid">I\'m a bit paranoid</string>
<string name="napplet_policy_paranoid_desc">Do not sign anything without asking me!</string>
<!-- Signer per-op consent dialog -->
<string name="napplet_signer_allow_once">Allow once</string>
<string name="napplet_signer_allow_op">Don\'t ask again to %1$s</string>
<string name="napplet_signer_allow_all">Don\'t ask again for any Nostr requests</string>
<string name="napplet_signer_deny_once">Deny</string>
<string name="napplet_signer_deny_op">Always deny %1$s</string>
<!-- Signer op labels -->
<string name="napplet_op_sign_kind">sign kind %1$d event</string>
<string name="napplet_op_encrypt">encrypt a message</string>
<string name="napplet_op_decrypt">decrypt a message</string>
<!-- Permissions management screen -->
<string name="napplet_permissions_title">Connected Apps</string>
<string name="napplet_permissions_revoke_all">Revoke all permissions</string>
<string name="napplet_permissions_overrides">Operation overrides</string>
<string name="napplet_signer_permissions_empty">No apps have connected yet.</string>
<string name="napplet_signer_permissions_revoke_all">Revoke all permissions</string>
<string name="nip82_repository_label">Source: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Download</string>
@@ -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<NappletCapability>,
): 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
}
}
}
}
}
@@ -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,
}
@@ -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,
}
@@ -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
}
@@ -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
}
@@ -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
}
}
@@ -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. `"<authorPubKey>:<identifier>"`).
*
* 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<String, AppSignerPolicy>
/**
* All per-operation overrides for [coordinate], keyed by [NostrSignerOp.key] — for the
* permissions-management screen.
*/
suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision>
/** 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<String, AppSignerPolicy>()
private val opDecisions = mutableMapOf<String, MutableMap<String, NostrOpDecision>>()
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<String, AppSignerPolicy> = lock.withLock { policies.toMap() }
override suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision> = lock.withLock { opDecisions[coordinate]?.toMap() ?: emptyMap() }
override suspend fun clearAll(coordinate: String) =
lock.withLock {
policies.remove(coordinate)
opDecisions.remove(coordinate)
Unit
}
}