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=<pubkey>`),
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.
This commit is contained in:
Claude
2026-06-01 22:33:42 +00:00
parent ae271d0ba7
commit e8a50bfa11
5 changed files with 58 additions and 9 deletions
@@ -1888,8 +1888,8 @@ class Account(
suspend fun <T : Event> signAnonymouslyAndBroadcast(
template: EventTemplate<T>,
broadcast: List<Event> = emptyList(),
anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()),
): T {
val anonymousSigner = NostrSignerInternal(KeyPair())
val event = anonymousSigner.sign(template)
cache.justConsumeMyOwnEvent(event)
@@ -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,
)
}
}
@@ -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)
@@ -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()
@@ -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<Long?>(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) {