mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
@@ -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
|
||||
|
||||
+154
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -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"
|
||||
}
|
||||
+151
@@ -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)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+87
@@ -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)
|
||||
}
|
||||
+25
-1
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+212
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+103
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -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,
|
||||
}
|
||||
+36
@@ -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,
|
||||
}
|
||||
+105
@@ -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
|
||||
}
|
||||
+71
@@ -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
|
||||
}
|
||||
+91
@@ -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
|
||||
}
|
||||
}
|
||||
+130
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user