From e8a50bfa1105bc03ff5709a76d8411a17af76358 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 22:33:42 +0000 Subject: [PATCH] fix: use ephemeral signer for media uploads in anonymous posts When composing an anonymous post (tap pfp to go anon on the short-note or comment screens), media uploads still authorized against the Blossom / NIP-96 server with the real account's signer. The server echoes that pubkey back in the returned media URL (e.g. Blossom's `as=`), linking the real identity to the supposedly anonymous post. Thread an optional `forcedSigner` through the upload chain (MultiOrchestrator -> UploadOrchestrator -> NIP-96/Blossom auth). Both ShortNotePostViewModel and CommentPostViewModel now hold a single ephemeral signer per compose session, reused for every photo/voice upload and for the final anonymous broadcast, so the upload auth event and the post share one throwaway key. signAnonymouslyAndBroadcast accepts that signer so the media author matches the post author. Non-anonymous callers are unaffected (forcedSigner defaults to null). The signer is reset in cancel() so each new compose session gets a fresh anonymous identity. --- .../vitorpamplona/amethyst/model/Account.kt | 2 +- .../service/uploads/MultiOrchestrator.kt | 5 ++++ .../service/uploads/UploadOrchestrator.kt | 29 +++++++++++++++---- .../nip22Comments/CommentPostViewModel.kt | 15 +++++++++- .../loggedIn/home/ShortNotePostViewModel.kt | 16 +++++++++- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1c91b076c5..e3dea0f4d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1888,8 +1888,8 @@ class Account( suspend fun signAnonymouslyAndBroadcast( template: EventTemplate, broadcast: List = emptyList(), + anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()), ): T { - val anonymousSigner = NostrSignerInternal(KeyPair()) val event = anonymousSigner.sign(template) cache.justConsumeMyOwnEvent(event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index 12e44faade..4f9af36bd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.ciphers.NostrCipher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope @@ -66,6 +67,7 @@ class MultiOrchestrator( stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): Result { coroutineScope { val jobs = @@ -84,6 +86,7 @@ class MultiOrchestrator( stripMetadata, onStrippingFailed, convertGifToMp4 = convertGifToMp4, + forcedSigner = forcedSigner, ) } } @@ -106,6 +109,7 @@ class MultiOrchestrator( stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): Result { coroutineScope { val jobs = @@ -125,6 +129,7 @@ class MultiOrchestrator( stripMetadata, onStrippingFailed, convertGifToMp4 = convertGifToMp4, + forcedSigner = forcedSigner, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index a230b37cf9..ed5b3e7b84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -30,7 +30,10 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.ciphers.NostrCipher import kotlinx.coroutines.flow.MutableStateFlow @@ -142,6 +145,7 @@ class UploadOrchestrator { contentTypeForResult: String?, originalHash: String?, account: Account, + forcedSigner: NostrSigner?, context: Context, ): UploadingFinalState { updateState(0.2, UploadingState.Uploading) @@ -158,7 +162,12 @@ class UploadOrchestrator { onProgress = { percent: Float -> updateState(0.2 + (0.2 * percent), UploadingState.Uploading) }, - httpAuth = account::createHTTPAuthorization, + httpAuth = + if (forcedSigner != null) { + { url, method, body -> forcedSigner.sign(HTTPAuthorizationEvent.build(url, method, body)) } + } else { + account::createHTTPAuthorization + }, context = context, ) @@ -187,6 +196,7 @@ class UploadOrchestrator { contentTypeForResult: String?, originalHash: String?, account: Account, + forcedSigner: NostrSigner?, context: Context, ): UploadingFinalState { updateState(0.2, UploadingState.Uploading) @@ -201,7 +211,12 @@ class UploadOrchestrator { sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, - httpAuth = account::createBlossomUploadAuth, + httpAuth = + if (forcedSigner != null) { + { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) } + } else { + account::createBlossomUploadAuth + }, context = context, ) @@ -360,6 +375,7 @@ class UploadOrchestrator { stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): UploadingFinalState { val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4) @@ -379,8 +395,8 @@ class UploadOrchestrator { try { return when (server.type) { ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) - ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) - ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) + ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) } } finally { deleteTempUri(finalUri, uri) @@ -401,6 +417,7 @@ class UploadOrchestrator { stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): UploadingFinalState { val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4) @@ -423,8 +440,8 @@ class UploadOrchestrator { try { return when (server.type) { ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) - ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) - ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) + ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) } } finally { deleteTempUri(encrypted.uri, uri) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 4f0758c6b8..70c44937c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -71,8 +71,11 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes @@ -215,6 +218,14 @@ open class CommentPostViewModel : var wantsAnonymousPost by mutableStateOf(false) + // A single ephemeral signer reused for the whole compose session so that media + // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed + // by the same throwaway key, instead of leaking the real account's pubkey into the + // upload authorization (and therefore into the returned media URL). + private var anonymousSignerCache: NostrSigner? = null + + fun anonymousSigner(): NostrSigner = anonymousSignerCache ?: NostrSignerInternal(KeyPair()).also { anonymousSignerCache = it } + fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -452,7 +463,7 @@ open class CommentPostViewModel : cancel() if (anonymous) { - accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) } else { accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) } @@ -619,6 +630,7 @@ open class CommentPostViewModel : context, stripMetadata = stripMetadata, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null, ) if (results.allGood) { @@ -711,6 +723,7 @@ open class CommentPostViewModel : wantsToAddGeoHash = false wantsSecretEmoji = false wantsAnonymousPost = false + anonymousSignerCache = null forwardZapTo.value = SplitBuilder() forwardZapToEditting.clearText() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 990b02a7c5..150353320c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -91,7 +91,10 @@ import com.vitorpamplona.quartz.experimental.zapPolls.minAmount import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash @@ -306,6 +309,14 @@ open class ShortNotePostViewModel : // Anonymous Reply var wantsAnonymousPost by mutableStateOf(false) + // A single ephemeral signer reused for the whole compose session so that media + // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed + // by the same throwaway key, instead of leaking the real account's pubkey into the + // upload authorization (and therefore into the returned media URL). + private var anonymousSignerCache: NostrSigner? = null + + fun anonymousSigner(): NostrSigner = anonymousSignerCache ?: NostrSignerInternal(KeyPair()).also { anonymousSignerCache = it } + // Scheduled posting: epoch seconds (UTC) when the post should be published. // Null = post immediately on Send (existing behavior). var scheduledForSec by mutableStateOf(null) @@ -870,7 +881,7 @@ open class ShortNotePostViewModel : } if (anonymous) { - accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) } else if (accountViewModel.settings.useTrackedBroadcasts()) { // Tracked broadcasting with progress feedback (non-blocking) val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) @@ -1138,6 +1149,7 @@ open class ShortNotePostViewModel : stripMetadata, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, convertGifToMp4 = convertGifToMp4, + forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null, ) if (results.allGood) { @@ -1235,6 +1247,7 @@ open class ShortNotePostViewModel : wantsExclusiveGeoPost = false wantsSecretEmoji = false wantsAnonymousPost = false + anonymousSignerCache = null scheduledForSec = null forwardZapTo.value = SplitBuilder() @@ -1467,6 +1480,7 @@ open class ShortNotePostViewModel : account = account, context = appContext, useH265 = false, + forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null, ) when (result) {