From 3ce9ddd696b6a19e5ce5bf26b1d14ea2f8bb0be4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:02:09 +0000 Subject: [PATCH] refactor(geohash-chat): own the anonymous identity on Account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the standalone GeohashChatIdentity object with an account-owned GeohashChatIdentityState (account.geohashIdentity), so the throwaway per-geohash identities are scoped to a single account and cached per cell. This also fixes a cross-account privacy leak: the old DeviceSeed fallback (used by bunker / external signers that can't reach a raw key) stored one seed under a single global preference key, so every account on the device shared it — producing identical throwaway identities and linking a user's alt accounts together in every cell. The seed now lives in the account's own encrypted store (keyed by pubkey), so different accounts get different seeds. Local-key accounts are unchanged: their identity is still derived from the account private key and stays stable across devices. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV --- .../vitorpamplona/amethyst/model/Account.kt | 3 + .../model/GeohashChatIdentityState.kt | 94 +++++++++++++++++++ .../service/geohash/GeohashChatIdentity.kt | 92 ------------------ .../chats/geohashChat/GeohashChatViewModel.kt | 13 ++- 4 files changed, 106 insertions(+), 96 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/geohash/GeohashChatIdentity.kt 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 f9f0d220e1..9451ea5a7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -573,6 +573,9 @@ class Account( val geohashListDecryptionCache = GeohashListDecryptionCache(signer) val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) + // Anonymous, per-geohash throwaway identities for Bitchat-interoperable location chats. + val geohashIdentity = GeohashChatIdentityState(signer) + val muteListDecryptionCache = MuteListDecryptionCache(signer) val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt new file mode 100644 index 0000000000..64ee244611 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt @@ -0,0 +1,94 @@ +/* + * 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.model + +import androidx.core.content.edit +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.experimental.bitchat.identity.GeohashKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * The account's anonymous, per-geohash chat identities. + * + * Geohash channels are location-tagged, so posting under the account's real npub + * would publish the user's movements tied to their public identity. Instead each + * cell gets a throwaway key that is unlinkable to the npub (and to the user's key + * in every other cell). This state object caches the derived keys and owns the + * seed they come from, keyed to a single account — so switching accounts (or + * logging out) switches identities with it. + * + * The seed is chosen per signer: + * - **Local key account** → derived from the account private key + * ([GeohashKeyDerivation.accountSeed]). Stable across all of the user's devices + * and recoverable from the account, while staying publicly unlinkable. + * - **Remote (NIP-46) / external (NIP-55) signer** → the raw key is unreachable, + * so a random 32-byte seed is kept in this account's encrypted storage. Because + * the store is scoped to the account's pubkey, two accounts on one device get + * different seeds (a global seed would have made their throwaway identities + * collide, linking the accounts in every cell). + */ +class GeohashChatIdentityState( + private val signer: NostrSigner, +) { + private val lock = Any() + private val cache = HashMap() + + @Volatile private var cachedDeviceSeed: ByteArray? = null + + /** The Nostr key pair to use inside [geohash]. Derivation is cheap but cached; call off the main thread. */ + fun keyPair(geohash: String): KeyPair = + synchronized(lock) { + cache.getOrPut(geohash) { GeohashKeyDerivation.deriveKeyPair(seed(), geohash) } + } + + private fun seed(): ByteArray = accountPrivKey()?.let { GeohashKeyDerivation.accountSeed(it) } ?: deviceSeed() + + private fun accountPrivKey(): ByteArray? = (signer as? NostrSignerInternal)?.keyPair?.privKey + + /** Random per-account seed, used only when the account key is unreachable (bunker / external signer). */ + private fun deviceSeed(): ByteArray { + cachedDeviceSeed?.let { return it } + synchronized(lock) { + cachedDeviceSeed?.let { return it } + val prefs = Amethyst.instance.encryptedStorage(signer.pubKey) + val existing = prefs.getString(PREF_KEY, null) + val seed = + if (existing != null && existing.length == GeohashKeyDerivation.SEED_SIZE * 2) { + existing.hexToByteArray() + } else { + val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE) + prefs.edit { putString(PREF_KEY, fresh.toHexKey()) } + fresh + } + cachedDeviceSeed = seed + return seed + } + } + + companion object { + private const val PREF_KEY = "geohash_chat_device_seed" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/geohash/GeohashChatIdentity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/geohash/GeohashChatIdentity.kt deleted file mode 100644 index 5d51ed6f12..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/geohash/GeohashChatIdentity.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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.service.geohash - -import androidx.core.content.edit -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.quartz.experimental.bitchat.identity.GeohashKeyDerivation -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.utils.RandomInstance - -/** - * Resolves the anonymous, per-geohash chat identity for the current user. - * - * Geohash channels are location-tagged, so posting under the account's real npub - * would publish the user's movements tied to their public identity. Instead each - * cell gets a throwaway key that is unlinkable to the npub (and to the user's key - * in other cells). - * - * The seed those keys derive from is chosen per signer: - * - **Local key account** → derived from the account private key - * ([GeohashKeyDerivation.accountSeed]). The identity is then stable across all - * of the user's devices and recoverable from the account, while staying - * publicly unlinkable. - * - **Remote (NIP-46) / external (NIP-55) signer** → we can't reach the raw key, - * so we fall back to [DeviceSeed]: a random 32-byte seed kept in the app's - * global encrypted storage (per-device, generated once). - */ -object GeohashChatIdentity { - /** The Nostr key pair to use inside [geohash] for [account]. Call off the main thread. */ - fun keyPair( - account: Account, - geohash: String, - ): KeyPair { - val seed = - accountPrivKey(account)?.let { GeohashKeyDerivation.accountSeed(it) } - ?: DeviceSeed.seed() - return GeohashKeyDerivation.deriveKeyPair(seed, geohash) - } - - private fun accountPrivKey(account: Account): ByteArray? = (account.signer as? NostrSignerInternal)?.keyPair?.privKey - - /** - * Fallback random device seed, used only when the account key is unreachable - * (bunker / external signer). - */ - object DeviceSeed { - private const val PREF_KEY = "geohash_chat_device_seed" - - @Volatile private var cached: ByteArray? = null - - fun seed(): ByteArray { - cached?.let { return it } - synchronized(this) { - cached?.let { return it } - val prefs = Amethyst.instance.encryptedStorage() - val existing = prefs.getString(PREF_KEY, null) - val seed = - if (existing != null && existing.length == GeohashKeyDerivation.SEED_SIZE * 2) { - existing.hexToByteArray() - } else { - val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE) - prefs.edit { putString(PREF_KEY, fresh.toHexKey()) } - fresh - } - cached = seed - return seed - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt index e4a2a4fc29..da6282a8a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.service.geohash.GeohashChatIdentity import com.vitorpamplona.amethyst.service.geohash.GeohashRelays import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent @@ -103,7 +102,13 @@ class GeohashChatViewModel : ViewModel() { } private suspend fun start() { - _myPubKey.value = withContext(Dispatchers.IO) { GeohashChatIdentity.keyPair(accountViewModel.account, geohash).pubKey.toHexKey() } + _myPubKey.value = + withContext(Dispatchers.IO) { + accountViewModel.account.geohashIdentity + .keyPair(geohash) + .pubKey + .toHexKey() + } _relays.value = resolveRelays() } @@ -127,7 +132,7 @@ class GeohashChatViewModel : ViewModel() { signer = account.signer pubKeyHex = account.signer.pubKey } else { - val keyPair = withContext(Dispatchers.IO) { GeohashChatIdentity.keyPair(account, geohash) } + val keyPair = withContext(Dispatchers.IO) { account.geohashIdentity.keyPair(geohash) } signer = NostrSignerInternal(keyPair) pubKeyHex = keyPair.pubKey.toHexKey() } @@ -150,7 +155,7 @@ class GeohashChatViewModel : ViewModel() { viewModelScope.launch { val relays = _relays.value.toSet().ifEmpty { resolveRelays().toSet() } if (relays.isEmpty()) return@launch - val keyPair = withContext(Dispatchers.IO) { GeohashChatIdentity.keyPair(accountViewModel.account, geohash) } + val keyPair = withContext(Dispatchers.IO) { accountViewModel.account.geohashIdentity.keyPair(geohash) } val signer = NostrSignerInternal(keyPair) val template = GeohashPresenceEvent.build(geohash, nickname = nickname?.ifBlank { null }) runCatching { accountViewModel.account.signWithAndSendPrivately(template, signer, relays) }