Add Primal like NostrPublisher

This commit is contained in:
Kgothatso Ngako
2026-04-18 21:58:32 +02:00
parent 76858f9cf0
commit 87bd722960
20 changed files with 538 additions and 7 deletions

View File

@@ -0,0 +1,40 @@
package ac.aux.compose.cryptography
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.UnsignedNostrEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import kotlin.time.Instant
fun UnsignedNostrEvent.signOrThrow(nsec: String): NostrEvent {
val hexPrivateKey = Bech32.decodeBytes(nsec).second
return this.signOrThrow(hexPrivateKey)
}
fun UnsignedNostrEvent.signOrThrow(hexPrivateKey: ByteArray): NostrEvent {
val tempSigner = NostrSignerSync(
KeyPair(
privKey = hexPrivateKey
)
)
val event = tempSigner.signNormal<Event>(
createdAt = this.createdAt.epochSeconds,
kind = this.kind,
tags = this.tags,
content = this.privateTags?.let { PrivateTagsInContent.encryptNip44(it, tempSigner) } ?: this.content
)
return NostrEvent(
id = event.id,
pubKey = event.pubKey,
kind = event.kind,
tags = event.tags,
content = event.content,
createdAt = Instant.fromEpochSeconds(event.createdAt),
sig = event.sig,
unsignedNostrEventId = this.id
)
}

View File

@@ -9,6 +9,7 @@ import ac.aux.compose.database.dao.PostDao
import ac.aux.compose.database.dao.ProfileDao
import ac.aux.compose.database.dao.ReactionDao
import ac.aux.compose.database.dao.RecentSearchDao
import ac.aux.compose.database.dao.RelayDao
import ac.aux.compose.database.dao.RepostDao
import ac.aux.compose.database.dao.SynchronizeNostrEventRequestDao
import ac.aux.compose.database.dao.SynchronizeNostrEventResultDao
@@ -66,6 +67,9 @@ abstract class AuxDatabase: RoomDatabase() {
abstract fun reactionDao(): ReactionDao
abstract fun recentSearchDao(): RecentSearchDao
abstract fun relayDao(): RelayDao
abstract fun repostDao(): RepostDao
abstract fun synchronizeNostrEventRequestDao(): SynchronizeNostrEventRequestDao

View File

@@ -13,7 +13,7 @@ interface RelayDao {
fun getAllPosts(): List<Relay>
@Query("SELECT * FROM Relay WHERE publicKey = :publicKey")
fun observePublicKeyRelays(publicKey: String): Flow<Relay?>
fun observePublicKeyRelays(publicKey: String): Flow<List<Relay>>
@Upsert
suspend fun upsert(relay: Relay)

View File

@@ -7,6 +7,7 @@ import ac.aux.compose.database.model.BroadcastNostrEventRequest
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.RecentSearch
import ac.aux.compose.database.model.Relay
import ac.aux.compose.database.model.SynchronizeNostrEventRequest
import ac.aux.compose.database.model.UnsignedNostrEvent
import ac.aux.compose.database.model.intermdiate.LocalBroadcastNostrEventRequest
@@ -16,6 +17,7 @@ import ac.aux.compose.database.model.intermdiate.LocalProfile
import ac.aux.compose.database.model.typealiases.SynchronizationFilterArray
import ac.aux.compose.nostr.Relays
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.repository.RelayRepository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -43,7 +45,7 @@ import kotlin.time.Instant
class DatabaseNostrRepository(
private val database: AuxDatabase
): NostrRepository {
): NostrRepository, RelayRepository {
companion object {
const val TAG = "DatabaseNostrRepository"
}
@@ -350,4 +352,8 @@ class DatabaseNostrRepository(
)
)
}
override suspend fun observePublicKeyRelays(publicKey: String): Flow<List<Relay>> {
return database.relayDao().observePublicKeyRelays(publicKey)
}
}

View File

@@ -0,0 +1,3 @@
package ac.aux.compose.exceptions
class InvalidNostrPrivateKeyException: RuntimeException()

View File

@@ -0,0 +1,3 @@
package ac.aux.compose.exceptions
class NostrPublishException(override val cause: Throwable?) : RuntimeException()

View File

@@ -0,0 +1,4 @@
package ac.aux.compose.exceptions
open class SignatureException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
}

View File

@@ -0,0 +1,8 @@
package ac.aux.compose.exceptions
class SigningKeyNotFoundException(message: String? = null, cause: Throwable? = null) : SignatureException(
message,
cause,
)
{
}

View File

@@ -0,0 +1,4 @@
package ac.aux.compose.exceptions
class SigningRejectedException(message: String? = null, cause: Throwable? = null) : SignatureException(message, cause) {
}

View File

@@ -0,0 +1,171 @@
package ac.aux.compose.managers
import ac.aux.compose.exceptions.InvalidNostrPrivateKeyException
import androidx.datastore.core.DataStore
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.lightning.Lightning
import fr.acinq.secp256k1.Hex
import io.ktor.utils.io.core.toByteArray
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
/***
* TODO: Make this a Singleton
*/
class CredentialsManager(
private val persistence: DataStore<Set<Credential>>,
) {
private val scope = CoroutineScope(Dispatchers.IO)
val credentials = persistence.data
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = runBlocking { persistence.data.first() },
)
private suspend fun addCredential(credential: Credential) = persistence.updateData { it + credential }
suspend fun clearCredentials() = persistence.updateData { emptySet() }
fun isExternalSignerCredential(npub: String) =
checkCredentialType(npub = npub, credentialType = CredentialType.ExternalSigner)
fun isNpubCredential(npub: String) = checkCredentialType(npub = npub, credentialType = CredentialType.PublicKey)
suspend fun getOrCreateInternalSignerCredentials() =
credentials.value.find { it.type == CredentialType.InternalSigner }
?: Lightning.randomKey().let { privateKey ->
Credential(
nsec = privateKey.value.toByteArray().toNsec(),
npub = privateKey.publicKey().value.toByteArray().toNpub(),
type = CredentialType.InternalSigner
)
}
private fun checkCredentialType(npub: String, credentialType: CredentialType) =
credentials.value.find { it.npub == npub }?.type == credentialType
suspend fun saveNsec(nostrKey: String): String {
val (nsec, pubkey) = nostrKey.extractKeyPairFromPrivateKeyOrThrow()
addCredential(Credential(nsec = nsec, npub = pubkey, type = CredentialType.PrivateKey))
return pubkey.bech32ToHexOrThrow()
}
suspend fun saveExternalSignerNpub(npub: String): String {
val (hexKey, bech32Key) = if (npub.startsWith("npub")) {
npub.bech32ToHexOrThrow() to npub
} else {
npub to npub.hexToNpubHrp()
}
addCredential(Credential(nsec = null, npub = bech32Key, type = CredentialType.ExternalSigner))
return hexKey
}
suspend fun saveNpub(npub: String): String {
addCredential(Credential(nsec = null, npub = npub, type = CredentialType.PublicKey))
return npub.bech32ToHexOrThrow()
}
suspend fun removeCredentialByNsec(nsec: String) =
persistence.updateData {
it.filterNot { cred -> cred.nsec == nsec }.toSet()
}
suspend fun removeCredentialByNpub(npub: String) =
persistence.updateData {
it.filterNot { cred -> cred.npub == npub }.toSet()
}
fun findOrThrow(npub: String): Credential =
credentials.value.find { it.npub == npub }
?: throw IllegalArgumentException("Credential not found for $npub.")
@Serializable
data class Credential(
val nsec: String?,
val npub: String,
val type: CredentialType = CredentialType.PrivateKey,
)
enum class CredentialType {
InternalSigner,
ExternalSigner,
PrivateKey,
PublicKey,
}
}
fun String.assureValidNsec() = if (startsWith("nsec")) this else this.hexToNsecHrp()
fun String.assureValidNpub() = if (startsWith("npub")) this else this.hexToNpubHrp()
fun String.assureValidPubKeyHex() = if (startsWith("npub")) this.bech32ToHexOrThrow() else this
fun String.hexToNoteHrp() =
Bech32.encodeBytes(
hrp = "note",
data = Hex.decode(this),
encoding = Bech32.Encoding.Bech32,
)
fun String.hexToNpubHrp() =
Bech32.encodeBytes(
hrp = "npub",
data = Hex.decode(this),
encoding = Bech32.Encoding.Bech32,
)
fun String.hexToNsecHrp() =
Bech32.encodeBytes(
hrp = "nsec",
data = Hex.decode(this),
encoding = Bech32.Encoding.Bech32,
)
fun String.urlToLnUrlHrp() =
Bech32.encodeBytes(
hrp = "lnurl",
data = this.toByteArray(),
encoding = Bech32.Encoding.Bech32,
)
fun String.bech32ToHexOrThrow() = Bech32.decodeBytes(bech32 = this).second.toHex()
fun String.bech32ToHexOrNull() = runCatching { this.bech32ToHexOrThrow() }.getOrNull()
fun ByteArray.toNpub() = Bech32.encodeBytes(hrp = "npub", this, Bech32.Encoding.Bech32)
@OptIn(ExperimentalStdlibApi::class)
fun ByteArray.toHex() = Hex.encode(this)
@Throws(IllegalArgumentException::class)
fun String.bechToBytesOrThrow(hrp: String? = null): ByteArray {
val decodedForm = Bech32.decodeBytes(this)
hrp?.also { require(it == decodedForm.first) }
return decodedForm.second
}
fun String.extractKeyPairFromPrivateKeyOrThrow(): Pair<String, String> {
return try {
val nsec = this.assureValidNsec()
val decoded = Bech32.decodeBytes(nsec)
val pubkey = PrivateKey(decoded.second).publicKey().value.toByteArray()
nsec to pubkey.toNpub()
} catch (error: IllegalArgumentException) {
Logger.withTag("String.extractKeyPairFromPrivateKeyOrThrow").w(error) { error.message ?: "" }
throw InvalidNostrPrivateKeyException()
}
}

View File

@@ -0,0 +1,15 @@
package ac.aux.compose.network
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
object UserAgent {
const val APP_NAME = "Aux"
const val CLIENT_NAME = "Aux"
}
fun String.asClientTag(): Array<String> = arrayOf(
"client",
this
)

View File

@@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import net.primal.android.networking.relays.errors.NostrPublishException
import ac.aux.compose.exceptions.NostrPublishException
import ac.aux.compose.network.sockets.NostrIncomingMessage
import ac.aux.compose.network.sockets.NostrSocketClient
import ac.aux.compose.network.sockets.NostrSocketClientFactory
@@ -30,6 +30,11 @@ import ac.aux.compose.network.sockets.parseIncomingMessage
import ac.aux.compose.repository.CachingImportRepository
import co.touchlab.kermit.Logger
/**
* As seen in Primal
*
* https://github.com/PrimalHQ/primal-android-app/blob/8a912053131764c39711cd0dc5012645498c9a4f/app/src/main/kotlin/net/primal/android/networking/relays/RelayPool.kt
*/
class RelayPool(
private val nostrSocketClientFactory: NostrSocketClientFactory,
private val cachingImportRepository: CachingImportRepository,

View File

@@ -15,9 +15,14 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.primal.android.networking.relays.errors.NostrPublishException
import ac.aux.compose.exceptions.NostrPublishException
/**
* As Seen in Primal
*
* https://github.com/PrimalHQ/primal-android-app/blob/6db2e6862239335dede4162338d7c4f10ad35031/app/src/main/kotlin/net/primal/android/networking/relays/RelaysSocketManager.kt
*/
class RelaysSocketManager constructor(
private val nostrSocketClientFactory: NostrSocketClientFactory,
private val cachingImportRepository: CachingImportRepository,

View File

@@ -1,3 +0,0 @@
package net.primal.android.networking.relays.errors
class NostrPublishException(override val cause: Throwable?) : RuntimeException()

View File

@@ -3,10 +3,41 @@ package ac.aux.compose.repository
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.network.relays.broadcast.BroadcastEventResponse
/**
* As seen in Primal
*
* https://github.com/PrimalHQ/primal-android-app/blob/a7e32e2203555d27e60a749238175cd1ee0fa2af/domain/primal/src/commonMain/kotlin/net/primal/domain/global/CachingImportRepository.kt
*
*/
interface CachingImportRepository {
suspend fun cacheNostrEvents(vararg events: NostrEvent)
suspend fun cacheNostrEvents(events: List<NostrEvent>)
suspend fun importEvents(events: List<NostrEvent>): Boolean
suspend fun broadcastEvents(events: List<NostrEvent>, relays: List<String>): Result<List<BroadcastEventResponse>>
companion object {
val NO_OP_CACHING_IMPORT_REPOSITORY = object : CachingImportRepository {
override suspend fun cacheNostrEvents(vararg events: NostrEvent) {
}
override suspend fun cacheNostrEvents(events: List<NostrEvent>) {
}
override suspend fun importEvents(events: List<NostrEvent>): Boolean {
return true
}
override suspend fun broadcastEvents(
events: List<NostrEvent>,
relays: List<String>
): Result<List<BroadcastEventResponse>> {
return Result.success(
emptyList()
)
}
}
}
}

View File

@@ -0,0 +1,153 @@
package ac.aux.compose.repository
import ac.aux.compose.cryptography.signOrThrow
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.UnsignedNostrEvent
import ac.aux.compose.exceptions.SignatureException
import ac.aux.compose.exceptions.SigningKeyNotFoundException
import ac.aux.compose.exceptions.SigningRejectedException
import ac.aux.compose.managers.CredentialsManager
import ac.aux.compose.managers.hexToNpubHrp
import ac.aux.compose.network.UserAgent
import ac.aux.compose.network.asClientTag
import ac.aux.compose.network.dto.RelayDTO
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* As seen in Primal
*
* https://github.com/PrimalHQ/primal-android-app/blob/86e8d29df7f780e8d19ccff2362d7dc6398da634/app/src/main/kotlin/net/primal/android/nostr/notary/NostrNotary.kt
*
* TODO: Make this a Singleton
*/
class NostrNotaryRepository(
private val nostrRepository: NostrRepository,
private val credentialsStore: CredentialsManager,
) {
private val scope = CoroutineScope(Dispatchers.Main)
private val _effects = Channel<NotarySideEffect>()
val effects = _effects.receiveAsFlow()
private fun setEffect(effect: NotarySideEffect) = scope.launch { _effects.send(effect) }
private val signMutex = Mutex()
private val _responses = Channel<SignResult>()
private fun setResponse(response: SignResult) = scope.launch { _responses.send(response) }
suspend fun signNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): SignResult {
val result = try {
signNostrEvent(publicKey = unsignedNostrEvent.pubKey, event = unsignedNostrEvent)
} catch (error: SignatureException) {
return SignResult.Rejected(error)
}
return if (result != null) {
SignResult.Signed(result)
} else {
signMutex.withLock {
setEffect(NotarySideEffect.RequestSignature(unsignedNostrEvent))
_responses.receive()
}
}
}
fun verifySignature(nostrEvent: NostrEvent): Boolean {
throw NotImplementedError()
}
fun onSuccess(nostrEvent: NostrEvent) {
setResponse(SignResult.Signed(nostrEvent))
}
fun onFailure() {
setResponse(SignResult.Rejected(SigningRejectedException()))
}
private fun findNsecOrThrow(pubkey: String): String =
runCatching {
val npub = Hex.decode(pubkey).toNpub()
credentialsStore.findOrThrow(npub = npub).nsec
}.getOrNull() ?: throw SigningKeyNotFoundException()
private fun signNostrEvent(publicKey: String, event: UnsignedNostrEvent): NostrEvent? {
val isExternalSignerLogin = runCatching {
credentialsStore.isExternalSignerCredential(npub = publicKey.hexToNpubHrp())
}.getOrDefault(false)
if (isExternalSignerLogin) {
throw NotImplementedError()
}
return event.signOrThrow(nsec = findNsecOrThrow(publicKey))
}
suspend fun signRelayListMetadata(userId: String, relays: List<RelayDTO>): SignResult {
return signNostrEvent(
unsignedNostrEvent = UnsignedNostrEvent(
pubKey = userId,
content = "",
kind = RelayFeedsListEvent.KIND,
tags = relays.map {
arrayOf(
"r",
it.url,
when {
it.read -> "read"
it.write -> "write"
else -> ""
}
)
}.toTypedArray() + listOf(UserAgent.CLIENT_NAME.asClientTag()),
),
)
}
sealed class NotarySideEffect {
data class RequestSignature(val unsignedEvent: UnsignedNostrEvent) : NotarySideEffect()
}
sealed class SignResult {
data class Signed(val event: NostrEvent) : SignResult()
data class Rejected(val error: SignatureException) : SignResult()
fun unwrapOrThrow(onFailure: ((SignatureException) -> Unit)? = null): NostrEvent =
when (this) {
is Rejected -> {
onFailure?.invoke(this.error)
throw this.error
}
is Signed -> {
this.event
}
}
fun getOrNull(onFailure: ((SignatureException) -> Unit)? = null): NostrEvent? =
when (this) {
is Rejected -> {
onFailure?.invoke(this.error)
null
}
is Signed -> {
this.event
}
}
fun getOrThrow(error: Throwable) =
when (this) {
is Rejected -> throw error
is Signed -> this.event
}
}
}

View File

@@ -0,0 +1,73 @@
package ac.aux.compose.repository
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.UnsignedNostrEvent
import ac.aux.compose.exceptions.SignatureException
import ac.aux.compose.network.dto.RelayDTO
import ac.aux.compose.network.relays.RelaysSocketManager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ac.aux.compose.exceptions.NostrPublishException
/**
* As Seen in Primal
*
* https://github.com/PrimalHQ/primal-android-app/blob/ec22916b4e3472a20bade82d53957a7ef9b708b0/app/src/main/kotlin/net/primal/android/nostr/publish/NostrPublisher.kt#L20
*/
class NostrPublisherRepository(
private val relaysSocketManager: RelaysSocketManager,
private val nostrNotary: NostrNotaryRepository,
private val cachingImportRepository: CachingImportRepository,
) {
val logger = Logger.withTag("NostrPublisherRepository")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private fun importEvent(event: NostrEvent) {
scope.launch {
runCatching {
cachingImportRepository.importEvents(events = listOf(event))
}.onFailure { error ->
logger.w(throwable = error) { "Failed to import event ${event.id} to caching server." }
}
}
}
@Throws(NostrPublishException::class)
private suspend fun publishAndImportEvent(signedNostrEvent: NostrEvent, outboxRelays: List<String> = emptyList()) {
relaysSocketManager.publishEvent(signedNostrEvent)
importEvent(signedNostrEvent)
if (outboxRelays.isNotEmpty()) {
runCatching {
relaysSocketManager.publishEvent(
nostrEvent = signedNostrEvent,
relays = outboxRelays.map { RelayDTO(url = it, read = false, write = true) },
)
}.onFailure { error ->
logger.w(throwable = error) { "Failed to publish to outbox relays." }
}
}
}
@Throws(NostrPublishException::class, SignatureException::class)
suspend fun signPublishImportNostrEvent(
unsignedNostrEvent: UnsignedNostrEvent,
outboxRelays: List<String>,
): NostrEvent {
val signedNostrEvent = nostrNotary.signNostrEvent(unsignedNostrEvent = unsignedNostrEvent).unwrapOrThrow()
publishAndImportEvent(signedNostrEvent = signedNostrEvent, outboxRelays = outboxRelays)
return signedNostrEvent
}
@Throws(NostrPublishException::class, SignatureException::class)
suspend fun publishRelayList(userId: String, relays: List<RelayDTO>): NostrEvent {
val signedNostrEvent = nostrNotary.signRelayListMetadata(userId = userId, relays = relays).unwrapOrThrow()
relaysSocketManager.publishEvent(nostrEvent = signedNostrEvent, relays = relays)
importEvent(signedNostrEvent)
return signedNostrEvent
}
}

View File

@@ -3,6 +3,8 @@ package ac.aux.compose.ui.view.model
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.managers.SeedManager
import ac.aux.compose.network.NostrEventBroadcaster
import ac.aux.compose.network.relays.RelaysSocketManager
import ac.aux.compose.network.sockets.NostrSocketClientFactory
import ac.aux.compose.nostr.Relays
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.view.state.NavigationUIState