- Adds a sync Signer to facilitate library

- Separates Account actions from Account state in two objects
- Changes Startup procedures to start with Account state and not the full account object
- Moves scope for flows in Account from an Application-wide scope to ViewModel scope
- Removes all LiveData objects from Account in favor of flows from the state object
- Migrates settings saving logic to flows
- Migrates PushNotification services to work without Account and only Account Settings.
- Migrates the spam filter from LiveData to Flows
- Adds Default lists for NIP-65 inbox and outbox relays
- Adds Default lists for Search relays
- Adds local backup for UserMetadata objects
- Adds local backup for Mute lists
- Adds local backup for NIP-65 relays
- Adds local backup for DM Relays
- Adds local backup for private home relays
- Rewrites state flows initializers to avoid inconsistent startups
This commit is contained in:
Vitor Pamplona
2024-08-26 14:42:55 -04:00
parent f3161ada8d
commit 4e3b6d0299
72 changed files with 2296 additions and 1736 deletions

View File

@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerSync
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -139,6 +140,17 @@ class AdvertisedRelayListEvent(
signer.sign(createdAt, KIND, tags, msg, onReady)
}
fun create(
list: List<AdvertisedRelayInfo>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): AdvertisedRelayListEvent? {
val tags = createTagArray(list)
val msg = ""
return signer.sign(createdAt, KIND, tags, msg)
}
}
@Immutable data class AdvertisedRelayInfo(

View File

@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerSync
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -97,5 +98,11 @@ class ChatMessageRelayListEvent(
) {
signer.sign(createdAt, KIND, createTagArray(relays), "", onReady)
}
fun create(
relays: List<String>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): ChatMessageRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "")
}
}

View File

@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerSync
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable data class Contact(
@@ -118,6 +119,46 @@ class ContactListEvent(
const val KIND = 3
const val ALT = "Follow List"
fun createFromScratch(
followUsers: List<Contact> = emptyList(),
followTags: List<String> = emptyList(),
followGeohashes: List<String> = emptyList(),
followCommunities: List<ATag> = emptyList(),
followEvents: List<String> = emptyList(),
relayUse: Map<String, ReadWrite>? = emptyMap(),
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): ContactListEvent? {
val content =
if (relayUse != null) {
mapper.writeValueAsString(relayUse)
} else {
""
}
val tags =
listOf(arrayOf("alt", ALT)) +
followUsers.map {
if (it.relayUri != null) {
arrayOf("p", it.pubKeyHex, it.relayUri)
} else {
arrayOf("p", it.pubKeyHex)
}
} +
followTags.map { arrayOf("t", it) } +
followEvents.map { arrayOf("e", it) } +
followCommunities.map {
if (it.relay != null) {
arrayOf("a", it.toTag(), it.relay)
} else {
arrayOf("a", it.toTag())
}
} +
followGeohashes.map { arrayOf("g", it) }
return signer.sign(createdAt, KIND, tags.toTypedArray(), content)
}
fun createFromScratch(
followUsers: List<Contact>,
followTags: List<String>,

View File

@@ -33,6 +33,7 @@ import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.fasterxml.jackson.module.kotlin.addDeserializer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.ATag

View File

@@ -78,7 +78,14 @@ class GiftWrapEvent(
) {
try {
plainContent(signer) { giftStr ->
val gift = fromJson(giftStr)
val gift =
try {
fromJson(giftStr)
} catch (e: Exception) {
Log.w("GiftWrapEvent", "Couldn't Parse the content " + this.toNostrUri() + " " + giftStr)
return@plainContent
}
if (gift is WrappedEvent) {
gift.host = HostStub(this.id, this.pubKey, this.kind)
}
@@ -87,7 +94,7 @@ class GiftWrapEvent(
onReady(gift)
}
} catch (e: Exception) {
Log.w("GiftWrapEvent", "Couldn't Decrypt the content", e)
Log.w("GiftWrapEvent", "Couldn't Decrypt the content " + this.toNostrUri())
}
}

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerSync
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -47,5 +48,12 @@ class LnZapPrivateEvent(
) {
signer.sign(createdAt, KIND, tags, content, onReady)
}
fun create(
signer: NostrSignerSync,
tags: Array<Array<String>> = emptyArray(),
content: String = "",
createdAt: Long = TimeUtils.now(),
): LnZapPrivateEvent? = signer.sign(createdAt, KIND, tags, content)
}
}

View File

@@ -26,6 +26,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerSync
import com.vitorpamplona.quartz.utils.TimeUtils
import java.io.ByteArrayInputStream
import java.io.StringWriter
@@ -176,6 +177,27 @@ class MetadataEvent(
companion object {
const val KIND = 0
fun newUser(
name: String?,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): MetadataEvent? {
// Tries to not delete any existing attribute that we do not work with.
val currentJson = ObjectMapper().createObjectNode()
name?.let { addIfNotBlank(currentJson, "name", it.trim()) }
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
val tags = mutableListOf<Array<String>>()
tags.add(
arrayOf("alt", "User profile for ${name ?: currentJson.get("name").asText() ?: ""}"),
)
return signer.sign(createdAt, KIND, tags.toTypedArray(), writer.buffer.toString())
}
fun updateFromPast(
latest: MetadataEvent?,
name: String?,

View File

@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerSync
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -97,5 +98,11 @@ class SearchRelayListEvent(
) {
signer.sign(createdAt, KIND, createTagArray(relays), "", onReady)
}
fun create(
relays: List<String>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): SearchRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "")
}
}

View File

@@ -20,20 +20,18 @@
*/
package com.vitorpamplona.quartz.signers
import android.util.Log
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventFactory
import com.vitorpamplona.quartz.events.LnZapPrivateEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
class NostrSignerInternal(
val keyPair: KeyPair,
) : NostrSigner(keyPair.pubKey.toHexKey()) {
val signerSync = NostrSignerSync(keyPair)
override fun <T : Event> sign(
createdAt: Long,
kind: Int,
@@ -41,46 +39,7 @@ class NostrSignerInternal(
content: String,
onReady: (T) -> Unit,
) {
if (keyPair.privKey == null) return
if (isUnsignedPrivateEvent(kind, tags)) {
// this is a private zap
signPrivateZap(createdAt, kind, tags, content, onReady)
} else {
signNormal(createdAt, kind, tags, content, onReady)
}
}
fun isUnsignedPrivateEvent(
kind: Int,
tags: Array<Array<String>>,
): Boolean =
kind == LnZapRequestEvent.KIND &&
tags.any { t -> t.size > 1 && t[0] == "anon" && t[1].isBlank() }
fun <T : Event> signNormal(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
onReady: (T) -> Unit,
) {
if (keyPair.privKey == null) return
val id = Event.generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, keyPair.privKey).toHexKey()
onReady(
EventFactory.create(
id.toHexKey(),
pubKey,
createdAt,
kind,
tags,
content,
sig,
) as T,
)
signerSync.sign<T>(createdAt, kind, tags, content)?.let { onReady(it) }
}
override fun nip04Encrypt(
@@ -88,15 +47,7 @@ class NostrSignerInternal(
toPublicKey: HexKey,
onReady: (String) -> Unit,
) {
if (keyPair.privKey == null) return
onReady(
CryptoUtils.encryptNIP04(
decryptedContent,
keyPair.privKey,
toPublicKey.hexToByteArray(),
),
)
signerSync.nip04Encrypt(decryptedContent, toPublicKey)?.let { onReady(it) }
}
override fun nip04Decrypt(
@@ -104,16 +55,7 @@ class NostrSignerInternal(
fromPublicKey: HexKey,
onReady: (String) -> Unit,
) {
if (keyPair.privKey == null) return
try {
val sharedSecret =
CryptoUtils.getSharedSecretNIP04(keyPair.privKey, fromPublicKey.hexToByteArray())
onReady(CryptoUtils.decryptNIP04(encryptedContent, sharedSecret))
} catch (e: Exception) {
Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on $encryptedContent")
}
signerSync.nip04Decrypt(encryptedContent, fromPublicKey)?.let { onReady(it) }
}
override fun nip44Encrypt(
@@ -121,16 +63,7 @@ class NostrSignerInternal(
toPublicKey: HexKey,
onReady: (String) -> Unit,
) {
if (keyPair.privKey == null) return
onReady(
CryptoUtils
.encryptNIP44(
decryptedContent,
keyPair.privKey,
toPublicKey.hexToByteArray(),
).encodePayload(),
)
signerSync.nip44Encrypt(decryptedContent, toPublicKey)?.let { onReady(it) }
}
override fun nip44Decrypt(
@@ -138,119 +71,13 @@ class NostrSignerInternal(
fromPublicKey: HexKey,
onReady: (String) -> Unit,
) {
if (keyPair.privKey == null) return
CryptoUtils
.decryptNIP44(
payload = encryptedContent,
privateKey = keyPair.privKey,
pubKey = fromPublicKey.hexToByteArray(),
)?.let { onReady(it) }
}
private fun <T> signPrivateZap(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
onReady: (T) -> Unit,
) {
if (keyPair.privKey == null) return
val zappedEvent = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] }
val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } ?: return
// if it is a Zap for an Event, use event.id if not, use the user's pubkey
val idToGeneratePrivateKey = zappedEvent ?: userHex
val encryptionPrivateKey =
LnZapRequestEvent.createEncryptionPrivateKey(
keyPair.privKey.toHexKey(),
idToGeneratePrivateKey,
createdAt,
)
val fullTagsNoAnon = tags.filter { t -> t.getOrNull(0) != "anon" }.toTypedArray()
LnZapPrivateEvent.create(this, fullTagsNoAnon, content) {
val noteJson = it.toJson()
val encryptedContent =
LnZapRequestEvent.encryptPrivateZapMessage(
noteJson,
encryptionPrivateKey,
userHex.hexToByteArray(),
)
val newTags =
tags.filter { t -> t.getOrNull(0) != "anon" } + listOf(arrayOf("anon", encryptedContent))
val newContent = ""
NostrSignerInternal(KeyPair(encryptionPrivateKey))
.signNormal(createdAt, kind, newTags.toTypedArray(), newContent, onReady)
}
signerSync.nip44Decrypt(encryptedContent, fromPublicKey)?.let { onReady(it) }
}
override fun decryptZapEvent(
event: LnZapRequestEvent,
onReady: (LnZapPrivateEvent) -> Unit,
) {
if (keyPair.privKey == null) return
val recipientPK = event.zappedAuthor().firstOrNull()
val recipientPost = event.zappedPost().firstOrNull()
val privateEvent =
if (recipientPK == pubKey) {
// if the receiver is logged in, these are the params.
val privateKeyToUse = keyPair.privKey
val pubkeyToUse = event.pubKey
event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse)
} else {
// if the sender is logged in, these are the params
val altPubkeyToUse = recipientPK
val altPrivateKeyToUse =
if (recipientPost != null) {
LnZapRequestEvent.createEncryptionPrivateKey(
keyPair.privKey.toHexKey(),
recipientPost,
event.createdAt,
)
} else if (recipientPK != null) {
LnZapRequestEvent.createEncryptionPrivateKey(
keyPair.privKey.toHexKey(),
recipientPK,
event.createdAt,
)
} else {
null
}
try {
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
val altPubKeyFromPrivate = CryptoUtils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
if (altPubKeyFromPrivate == event.pubKey) {
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
if (result == null) {
Log.w(
"Private ZAP Decrypt",
"Fail to decrypt Zap from ${event.id}",
)
}
result
} else {
null
}
} else {
null
}
} catch (e: Exception) {
Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e)
null
}
}
privateEvent?.let { onReady(it) }
signerSync.decryptZapEvent(event)?.let { onReady(it) }
}
}

View File

@@ -0,0 +1,241 @@
/**
* Copyright (c) 2024 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.quartz.signers
import android.util.Log
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventFactory
import com.vitorpamplona.quartz.events.LnZapPrivateEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
class NostrSignerSync(
val keyPair: KeyPair,
val pubKey: HexKey = keyPair.pubKey.toHexKey(),
) {
fun <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): T? {
if (keyPair.privKey == null) return null
return if (isUnsignedPrivateZapEvent(kind, tags)) {
// this is a private zap
signPrivateZap(createdAt, kind, tags, content)
} else {
signNormal(createdAt, kind, tags, content)
}
}
fun isUnsignedPrivateZapEvent(
kind: Int,
tags: Array<Array<String>>,
): Boolean =
kind == LnZapRequestEvent.KIND &&
tags.any { t -> t.size > 1 && t[0] == "anon" && t[1].isBlank() }
fun <T : Event> signNormal(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): T? {
if (keyPair.privKey == null) return null
val id = Event.generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, keyPair.privKey).toHexKey()
return EventFactory.create(
id.toHexKey(),
pubKey,
createdAt,
kind,
tags,
content,
sig,
) as T
}
fun nip04Encrypt(
decryptedContent: String,
toPublicKey: HexKey,
): String? {
if (keyPair.privKey == null) return null
return CryptoUtils.encryptNIP04(
decryptedContent,
keyPair.privKey,
toPublicKey.hexToByteArray(),
)
}
fun nip04Decrypt(
encryptedContent: String,
fromPublicKey: HexKey,
): String? {
if (keyPair.privKey == null) return null
return try {
val sharedSecret =
CryptoUtils.getSharedSecretNIP04(keyPair.privKey, fromPublicKey.hexToByteArray())
CryptoUtils.decryptNIP04(encryptedContent, sharedSecret)
} catch (e: Exception) {
Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on $encryptedContent")
null
}
}
fun nip44Encrypt(
decryptedContent: String,
toPublicKey: HexKey,
): String? {
if (keyPair.privKey == null) return null
return CryptoUtils
.encryptNIP44(
decryptedContent,
keyPair.privKey,
toPublicKey.hexToByteArray(),
).encodePayload()
}
fun nip44Decrypt(
encryptedContent: String,
fromPublicKey: HexKey,
): String? {
if (keyPair.privKey == null) return null
return CryptoUtils
.decryptNIP44(
payload = encryptedContent,
privateKey = keyPair.privKey,
pubKey = fromPublicKey.hexToByteArray(),
)
}
private fun <T> signPrivateZap(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): T? {
if (keyPair.privKey == null) return null
val zappedEvent = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] }
val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } ?: return null
// if it is a Zap for an Event, use event.id if not, use the user's pubkey
val idToGeneratePrivateKey = zappedEvent ?: userHex
val encryptionPrivateKey =
LnZapRequestEvent.createEncryptionPrivateKey(
keyPair.privKey.toHexKey(),
idToGeneratePrivateKey,
createdAt,
)
val fullTagsNoAnon = tags.filter { t -> t.getOrNull(0) != "anon" }.toTypedArray()
val privateEvent = LnZapPrivateEvent.create(this, fullTagsNoAnon, content) ?: return null
val noteJson = privateEvent.toJson()
val encryptedContent =
LnZapRequestEvent.encryptPrivateZapMessage(
noteJson,
encryptionPrivateKey,
userHex.hexToByteArray(),
)
val newTags =
tags.filter { t -> t.getOrNull(0) != "anon" } + listOf(arrayOf("anon", encryptedContent))
val newContent = ""
return NostrSignerSync(KeyPair(encryptionPrivateKey)).signNormal(createdAt, kind, newTags.toTypedArray(), newContent)
}
fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent? {
if (keyPair.privKey == null) return null
val recipientPK = event.zappedAuthor().firstOrNull()
val recipientPost = event.zappedPost().firstOrNull()
val privateEvent =
if (recipientPK == pubKey) {
// if the receiver is logged in, these are the params.
val privateKeyToUse = keyPair.privKey
val pubkeyToUse = event.pubKey
event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse)
} else {
// if the sender is logged in, these are the params
val altPubkeyToUse = recipientPK
val altPrivateKeyToUse =
if (recipientPost != null) {
LnZapRequestEvent.createEncryptionPrivateKey(
keyPair.privKey.toHexKey(),
recipientPost,
event.createdAt,
)
} else if (recipientPK != null) {
LnZapRequestEvent.createEncryptionPrivateKey(
keyPair.privKey.toHexKey(),
recipientPK,
event.createdAt,
)
} else {
null
}
try {
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
val altPubKeyFromPrivate = CryptoUtils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
if (altPubKeyFromPrivate == event.pubKey) {
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
if (result == null) {
Log.w(
"Private ZAP Decrypt",
"Fail to decrypt Zap from ${event.id}",
)
}
result
} else {
null
}
} else {
null
}
} catch (e: Exception) {
Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e)
null
}
}
return privateEvent
}
}