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 483352cd5..0748d7857 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -257,6 +257,7 @@ class Account( val scope: CoroutineScope, val mlsGroupStateStore: MlsGroupStateStore? = null, val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, + val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, ) : IAccount { private var userProfileCache: User? = null @@ -387,7 +388,7 @@ class Account( val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) - val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore) } + val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) } val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings) @@ -1905,6 +1906,43 @@ class Account( client.publish(event, outboxRelays.flow.value) } + /** + * Ensure the local user has at least one active KeyPackage bundle and + * a published KeyPackage event on relays. Called from [init] after + * Marmot state has been restored from disk. + * + * - If [KeyPackageRotationManager] already has an active bundle (from + * the persisted snapshot), we trust the previous session and do + * nothing. The matching kind:30443 should already be on relays from + * when the bundle was first generated. + * - Otherwise we generate a fresh bundle (which is now persisted to + * disk by [KeyPackageRotationManager.generateKeyPackage]) and + * publish the corresponding event. + * + * Best-effort: failures are logged but never propagated. We don't want + * a flaky relay or missing outbox config at startup to crash account + * initialization. + */ + private suspend fun ensureMarmotKeyPackagePublished() { + val manager = marmotManager ?: return + if (!isWriteable()) return + try { + if (manager.hasActiveKeyPackages()) { + Log.d("Account") { + "ensureMarmotKeyPackagePublished: already have an active KeyPackage bundle" + } + return + } + Log.d("Account") { + "ensureMarmotKeyPackagePublished: no active bundle — generating + publishing" + } + publishMarmotKeyPackage() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("Account", "ensureMarmotKeyPackagePublished failed: ${e.message}", e) + } + } + /** * Check if a KeyPackage has been published, either locally generated * in this session or found in the local cache from a previous session. @@ -2451,6 +2489,21 @@ class Account( if (marmotManager != null) { scope.launch(Dispatchers.IO) { marmotManager.restoreAll() + + // Ensure the local user has a KeyPackage published to relays + // so other users can invite them to groups. Without this, + // freshly installed accounts (and accounts that never opened + // the Marmot Group screen) would never have an active + // KeyPackage on the relays, and any inviter trying to add + // them would fail with "No KeyPackage found". + // + // The KeyPackage bundle (private keys included) is persisted + // by KeyPackageRotationManager via marmotKeyPackageStore, so + // restoreAll() above has already restored any previously + // generated bundles. Only generate-and-publish if no active + // bundle exists in memory after restore. + ensureMarmotKeyPackagePublished() + // Sync MIP-01 metadata from restored groups to chatrooms and // re-hydrate decrypted messages from persistent storage. // Note: Marmot MLS application messages cannot be re-decrypted diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 2e081d05c..baf2ee137 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -24,6 +24,7 @@ import android.content.ContentResolver import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore @@ -130,6 +131,18 @@ class AccountCacheState( null } + val marmotKeyPackageStore = + try { + AndroidKeyPackageBundleStore(rootFilesDir()) + } catch (e: Exception) { + Log.e( + "AccountCacheState", + "Failed to initialize AndroidKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)", + e, + ) + null + } + return Account( settings = accountSettings, signer = signerWithClientTag, @@ -148,6 +161,7 @@ class AccountCacheState( ), mlsGroupStateStore = mlsStore, marmotMessageStore = marmotMessageStore, + marmotKeyPackageStore = marmotKeyPackageStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt new file mode 100644 index 000000000..1a18bf77b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt @@ -0,0 +1,127 @@ +/* + * 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.marmot + +import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Android implementation of [KeyPackageBundleStore] using file-based encrypted storage. + * + * Storage layout: + * ``` + * /marmot_keypackages/state — encrypted snapshot + * ``` + * + * The blob contains private key material — init keys, encryption keys, + * signature keys — that the MLS engine needs to process Welcome events + * received days or weeks after the corresponding KeyPackage was published. + * It is encrypted at rest with [KeyStoreEncryption] (AES/GCM via Android + * KeyStore), the same primitive used by [AndroidMlsGroupStateStore]. + */ +class AndroidKeyPackageBundleStore( + private val rootDir: File, + private val encryption: KeyStoreEncryption = KeyStoreEncryption(), +) : KeyPackageBundleStore { + private val mutex = Mutex() + + init { + Log.d(TAG) { + "Initialized AndroidKeyPackageBundleStore: rootDir=${rootDir.absolutePath}" + } + } + + private fun stateFile(): File = File(rootDir, "marmot_keypackages/state") + + override suspend fun save(snapshot: ByteArray) = + withContext(Dispatchers.IO) { + mutex.withLock { + val file = stateFile() + try { + file.parentFile?.mkdirs() + val encrypted = encryption.encrypt(snapshot) + atomicWrite(file, encrypted) + Log.d(TAG) { + "save(): wrote ${encrypted.size} encrypted bytes (${snapshot.size} plain)" + } + } catch (e: Exception) { + Log.e(TAG, "save() FAILED: ${e.message}", e) + throw e + } + } + } + + override suspend fun load(): ByteArray? = + withContext(Dispatchers.IO) { + val file = stateFile() + if (!file.exists()) { + Log.d(TAG) { "load(): no state file at ${file.absolutePath}" } + return@withContext null + } + try { + val encrypted = file.readBytes() + val decrypted = encryption.decrypt(encrypted) + Log.d(TAG) { + "load(): read ${encrypted.size} encrypted bytes → ${decrypted?.size ?: -1} plain" + } + decrypted + } catch (e: Exception) { + Log.e(TAG, "load() FAILED: ${e.message}", e) + null + } + } + + override suspend fun delete() { + withContext(Dispatchers.IO) { + mutex.withLock { + val file = stateFile() + if (file.exists()) { + Log.w(TAG) { "delete(): removing ${file.absolutePath}" } + file.delete() + } + } + } + } + + private fun atomicWrite( + target: File, + data: ByteArray, + ) { + val tempFile = File(target.parentFile, "${target.name}.tmp") + tempFile.writeBytes(data) + if (!tempFile.renameTo(target)) { + tempFile.copyTo(target, overwrite = true) + if (!tempFile.delete()) { + Log.w(TAG) { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" } + } + } + } + + companion object { + private const val TAG = "AndroidKeyPackageBundleStore" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index 1f4b1f838..6154ade01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -89,16 +89,9 @@ fun MarmotGroupListScreen( } } - // Auto-publish KeyPackage if none exists yet - LaunchedEffect(Unit) { - if (!accountViewModel.hasPublishedKeyPackage()) { - try { - accountViewModel.publishMarmotKeyPackage() - } catch (_: Exception) { - // Silently retry on next screen visit - } - } - } + // KeyPackage publishing is handled at Account startup + // (Account.ensureMarmotKeyPackagePublished), so this screen no longer + // needs to do anything to make sure invitees can find a KeyPackage. val knownGroups = remember(groupList) { groupList.filter { it.second.ownerSentMessage } } val newRequestGroups = remember(groupList) { groupList.filter { !it.second.ownerSentMessage } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 209d930fa..b753fe9dd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.marmot.MarmotWelcomeSender import com.vitorpamplona.quartz.marmot.OutboundGroupEvent import com.vitorpamplona.quartz.marmot.WelcomeDelivery import com.vitorpamplona.quartz.marmot.WelcomeResult +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils @@ -65,9 +66,10 @@ class MarmotManager( val signer: NostrSigner, store: MlsGroupStateStore, val messageStore: MarmotMessageStore? = null, + val keyPackageStore: KeyPackageBundleStore? = null, ) { val groupManager = MlsGroupManager(store) - val keyPackageRotationManager = KeyPackageRotationManager() + val keyPackageRotationManager = KeyPackageRotationManager(keyPackageStore) val subscriptionManager = MarmotSubscriptionManager(signer.pubKey) val inboundProcessor = MarmotInboundProcessor(groupManager, keyPackageRotationManager) val outboundProcessor = MarmotOutboundProcessor(groupManager) @@ -83,6 +85,9 @@ class MarmotManager( groupManager.restoreAll() val activeIds = groupManager.activeGroupIds() subscriptionManager.syncWithGroupManager(activeIds) + // Also restore previously-published KeyPackage bundles so that + // Welcomes referencing them remain processable across restarts. + keyPackageRotationManager.restoreFromStore() Log.d("MarmotManager") { "restoreAll(): done, ${activeIds.size} groups: $activeIds" } } catch (e: Exception) { Log.e("MarmotManager", "Failed to restore Marmot state", e) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageBundleStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageBundleStore.kt new file mode 100644 index 000000000..edfcc6fe0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageBundleStore.kt @@ -0,0 +1,58 @@ +/* + * 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.quartz.marmot.mip00KeyPackages + +/** + * Encrypted local storage for the [KeyPackageRotationManager]'s state. + * + * KeyPackageBundles contain private key material (init key, encryption key, + * signature key) that the local user MUST retain on disk so they can process + * Welcome events the inviter sends days or weeks after publishing the + * KeyPackage. Without this storage, every app restart would discard the + * bundles and the user would have to re-publish a fresh KeyPackage — + * meanwhile, any inviter holding the old (now-orphaned) KeyPackage would be + * unable to add them to a group, because the matching private key is gone. + * + * Implementations MUST encrypt all data at rest. The persisted blob is a + * single opaque snapshot of the rotation manager state — both the active + * bundles map and the pending-rotation set are serialized together so they + * stay consistent across crashes. + */ +interface KeyPackageBundleStore { + /** + * Persist a snapshot of the rotation manager state. + * Overwrites any previously saved state. + * + * @param snapshot opaque encoded bytes from + * [KeyPackageRotationManager.snapshotBytes] + */ + suspend fun save(snapshot: ByteArray) + + /** + * Load a previously saved snapshot, or null if none exists. + */ + suspend fun load(): ByteArray? + + /** + * Delete the persisted state (e.g., when wiping the account). + */ + suspend fun delete() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index e030d4583..ea4ae1417 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.mip00KeyPackages +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 @@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -51,11 +54,110 @@ import kotlinx.coroutines.sync.withLock * Per MIP-00 spec, each user should maintain up to [KeyPackageUtils.MAX_SLOTS] * KeyPackage slots, rotating consumed ones promptly. */ -class KeyPackageRotationManager { +class KeyPackageRotationManager( + private val store: KeyPackageBundleStore? = null, +) { private val mutex = Mutex() private val activeBundles = mutableMapOf() private val pendingRotations = mutableSetOf() + /** + * Restore previously persisted bundles + rotation state from [store]. + * Call once at startup before any other use of this manager. + * Safe to call when no store is configured (no-op in that case). + */ + suspend fun restoreFromStore() { + val store = store ?: return + val bytes = + try { + store.load() + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to load persisted KeyPackages: ${e.message}") + null + } ?: return + try { + val decoded = decodeSnapshot(bytes) + mutex.withLock { + activeBundles.clear() + activeBundles.putAll(decoded.first) + pendingRotations.clear() + pendingRotations.addAll(decoded.second) + } + Log.d("KeyPackageRotationManager") { + "Restored ${decoded.first.size} active KeyPackage bundle(s), ${decoded.second.size} pending rotation" + } + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to decode persisted KeyPackages: ${e.message}") + } + } + + /** + * Encode the current rotation manager state to opaque bytes for + * persistence. Caller must hold the mutex. + */ + private fun snapshotBytesUnlocked(): ByteArray { + val writer = TlsWriter() + // version + writer.putUint16(SNAPSHOT_VERSION) + // active bundles + writer.putUint32(activeBundles.size.toLong()) + for ((slot, bundle) in activeBundles) { + writer.putOpaque2(slot.encodeToByteArray()) + writer.putOpaque4(bundle.keyPackage.toTlsBytes()) + writer.putOpaque2(bundle.initPrivateKey) + writer.putOpaque2(bundle.encryptionPrivateKey) + writer.putOpaque2(bundle.signaturePrivateKey) + } + // pending rotations + writer.putUint32(pendingRotations.size.toLong()) + for (slot in pendingRotations) { + writer.putOpaque2(slot.encodeToByteArray()) + } + return writer.toByteArray() + } + + /** + * Decode the persisted snapshot. Returns (activeBundles, pendingRotations). + */ + private fun decodeSnapshot(bytes: ByteArray): Pair, Set> { + val reader = TlsReader(bytes) + val version = reader.readUint16() + require(version == SNAPSHOT_VERSION) { + "Unsupported KeyPackage snapshot version: $version" + } + val numBundles = reader.readUint32().toInt() + val bundles = mutableMapOf() + repeat(numBundles) { + val slot = reader.readOpaque2().decodeToString() + val kpBytes = reader.readOpaque4() + val keyPackage = MlsKeyPackage.decodeTls(TlsReader(kpBytes)) + val initPriv = reader.readOpaque2() + val encPriv = reader.readOpaque2() + val sigPriv = reader.readOpaque2() + bundles[slot] = KeyPackageBundle(keyPackage, initPriv, encPriv, sigPriv) + } + val numPending = reader.readUint32().toInt() + val pending = mutableSetOf() + repeat(numPending) { + pending.add(reader.readOpaque2().decodeToString()) + } + return bundles to pending + } + + /** + * Persist the current state to [store]. Caller must hold the mutex. + * Best-effort: persistence failures are logged but don't propagate, so + * a failing disk write never breaks an in-flight Welcome / addMember. + */ + private suspend fun persistUnlocked() { + val store = store ?: return + try { + store.save(snapshotBytesUnlocked()) + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to persist KeyPackages: ${e.message}") + } + } + /** * Generate a new KeyPackage and its associated private bundle. * @@ -98,6 +200,7 @@ class KeyPackageRotationManager { val bundle = KeyPackageBundle(keyPackage, initKp.privateKey, encKp.privateKey, sigKp.privateKey) mutex.withLock { activeBundles[dTagSlot] = bundle + persistUnlocked() } return bundle } @@ -128,6 +231,7 @@ class KeyPackageRotationManager { mutex.withLock { activeBundles.remove(dTagSlot) pendingRotations.add(dTagSlot) + persistUnlocked() } /** @@ -142,6 +246,7 @@ class KeyPackageRotationManager { if (entry != null) { activeBundles.remove(entry.key) pendingRotations.add(entry.key) + persistUnlocked() } } @@ -157,6 +262,7 @@ class KeyPackageRotationManager { suspend fun clearPendingRotation(dTagSlot: String) = mutex.withLock { pendingRotations.remove(dTagSlot) + persistUnlocked() } /** @@ -184,6 +290,7 @@ class KeyPackageRotationManager { val bundle = generateKeyPackage(identity, dTagSlot) mutex.withLock { pendingRotations.remove(dTagSlot) + persistUnlocked() } return bundle } @@ -233,5 +340,8 @@ class KeyPackageRotationManager { /** Proactive rotation after 7 days even if not consumed */ const val MAX_KEY_PACKAGE_AGE_SECONDS = 7L * 24 * 60 * 60 + + /** On-disk snapshot format version for [KeyPackageBundleStore]. */ + private const val SNAPSHOT_VERSION = 1 } }