fix(marmot): persist KeyPackage bundles + auto-publish at startup
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:
<rootDir>/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.
This commit is contained in:
@@ -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
|
||||
|
||||
+14
@@ -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))
|
||||
|
||||
+127
@@ -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:
|
||||
* ```
|
||||
* <rootDir>/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"
|
||||
}
|
||||
}
|
||||
+3
-10
@@ -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 } }
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
+58
@@ -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()
|
||||
}
|
||||
+111
-1
@@ -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<String, KeyPackageBundle>()
|
||||
private val pendingRotations = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* 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<Map<String, KeyPackageBundle>, Set<String>> {
|
||||
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<String, KeyPackageBundle>()
|
||||
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<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user