From d5ec7fcaf36372c2f0e3f87248c0d56527f302ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 22:18:45 +0000 Subject: [PATCH] fix(marmot): persist KeyPackage bundles + auto-publish at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When user A wants to invite user B to a Marmot group, A must fetch B's published KeyPackage (kind:30443) from relays. If B has never had one published, A's `fetchKeyPackageAndAddMember` returns "No KeyPackage found" and the invite silently fails — which matches the user's report that "creating a new group with another user, nothing happens for that user". Two fixes: ### 1. Persist KeyPackageBundles to disk `KeyPackageRotationManager` was 100% in-memory: every restart wiped both the active bundles (with their private init/encryption/signature keys) AND the pending-rotation set. The relay echo of the kind:30443 event is meaningless without the matching private keys, so once a session ended the user could no longer process Welcome events referencing the published KeyPackage. This change adds a quartz `KeyPackageBundleStore` interface and an Android `AndroidKeyPackageBundleStore` implementation backed by the same `KeyStoreEncryption` (AES/GCM via Android KeyStore) used by `AndroidMlsGroupStateStore`. Storage layout: /marmot_keypackages/state — encrypted snapshot `KeyPackageRotationManager` accepts the store via constructor, encodes the `(activeBundles, pendingRotations)` pair into a versioned TLS snapshot, and persists after every state-mutating call: `generateKeyPackage`, `markConsumed`, `markConsumedByRef`, `clearPendingRotation`, `rotateSlot`. A new `restoreFromStore()` method is invoked from `MarmotManager.restoreAll()` at startup. The store is wired through: MarmotManager(... keyPackageStore) → Account(... marmotKeyPackageStore) → AccountCacheState (constructs AndroidKeyPackageBundleStore) ### 2. Ensure a published KeyPackage at app startup `Account.init` now calls a new private `ensureMarmotKeyPackagePublished` right after `marmotManager.restoreAll()`. If no active bundle exists in memory after the restore (i.e., the user never published one), it generates a fresh bundle (which the rotation manager auto-persists) and publishes the corresponding `KeyPackageEvent` to the user's outbox relays. Best-effort — failures are logged and swallowed so a flaky relay or missing outbox config at startup doesn't break account init. The redundant `LaunchedEffect` in `MarmotGroupListScreen` that did the same thing on first screen visit is now removed: KeyPackage publishing is no longer tied to the user navigating to the group list. This means freshly installed accounts (and any account that had never opened the Marmot Groups screen) will now have a published KeyPackage on relays as soon as the account loads, so other users can actually invite them. Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`, `:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL. --- .../vitorpamplona/amethyst/model/Account.kt | 55 +++++++- .../model/accountsCache/AccountCacheState.kt | 14 ++ .../marmot/AndroidKeyPackageBundleStore.kt | 127 ++++++++++++++++++ .../marmotGroup/MarmotGroupListScreen.kt | 13 +- .../amethyst/commons/marmot/MarmotManager.kt | 7 +- .../mip00KeyPackages/KeyPackageBundleStore.kt | 58 ++++++++ .../KeyPackageRotationManager.kt | 112 ++++++++++++++- 7 files changed, 373 insertions(+), 13 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageBundleStore.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 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 } }