fix(marmot): discard legacy KeyPackage snapshots on upgrade
The diagnostic trace from the invitee shows the welcome flow working
end-to-end (gift wrap → seal → processMarmotWelcomeFlow) but finally
failing at
MarmotInboundProcessor.processWelcome: NO matching KeyPackageBundle
for eventId=41177359… — inviter referenced a KeyPackage we don't
have private keys for
The eventId the welcome carries IS on relays — the inviter just
fetched it successfully in `fetchKeyPackageAndAddMember` — but on the
invitee's device the `eventIdToSlot` map is empty, so the lookup
misses. Root cause: the invitee's persisted KeyPackage snapshot is
in v1 format (from before the eventId→slot index existed).
`restoreFromStore` loaded v1 just fine, leaving bundles in place but
the eventId map empty. Then `Account.ensureMarmotKeyPackagePublished`
saw `hasActiveKeyPackages()` return true and *skipped republishing*
— so the stale kind:30443 that the inviter just fetched has no
matching mapping on the invitee.
Fix: `restoreFromStore` now refuses to load any snapshot whose
version is not the current `SNAPSHOT_VERSION` (2). On a v1 file it:
1. Logs a warning.
2. Deletes the on-disk snapshot via `store.delete()` so the next
save writes fresh v2.
3. Returns without touching `activeBundles` / `pendingRotations` /
`eventIdToSlot`.
This means `hasActiveKeyPackages()` will return false right after
restoreAll finishes, `ensureMarmotKeyPackagePublished` will generate
+ publish a fresh bundle (which `recordPublishedEventId` indexes by
its real Nostr event id), the new kind:30443 replaces the old one on
relays via d-tag addressability, and the next inviter's
`fetchKeyPackageAndAddMember` will find the new event + the invitee
will have the matching private keys to process its Welcome.
The same defensive wipe also fires for a v2 snapshot that somehow
carries bundles with an empty eventId map (corner case, crash during
upgrade, etc.).
Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
This commit is contained in:
+47
-8
@@ -79,6 +79,14 @@ class KeyPackageRotationManager(
|
||||
* 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).
|
||||
*
|
||||
* NOTE: v1 snapshots are deliberately NOT loaded. v1 predates the
|
||||
* `eventIdToSlot` index, so any bundles it holds would be unreachable
|
||||
* to the Welcome path (which looks up bundles by Nostr event id). The
|
||||
* cleanest upgrade is to discard the v1 state, let
|
||||
* `Account.ensureMarmotKeyPackagePublished` regenerate + republish
|
||||
* fresh KeyPackages, and rely on d-tag replacement on relays to
|
||||
* replace the stale kind:30443 events.
|
||||
*/
|
||||
suspend fun restoreFromStore() {
|
||||
val store = store ?: return
|
||||
@@ -91,6 +99,36 @@ class KeyPackageRotationManager(
|
||||
} ?: return
|
||||
try {
|
||||
val decoded = decodeSnapshot(bytes)
|
||||
if (decoded == null) {
|
||||
Log.w("KeyPackageRotationManager") {
|
||||
"Discarding legacy v1 KeyPackage snapshot — bundles will be regenerated and republished"
|
||||
}
|
||||
// Drop any state the caller may have set and force
|
||||
// `ensureMarmotKeyPackagePublished` to publish fresh ones.
|
||||
try {
|
||||
store.delete()
|
||||
} catch (e: Exception) {
|
||||
Log.w("KeyPackageRotationManager", "Failed to delete legacy snapshot: ${e.message}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// v2 snapshot: if bundles were restored but the eventId
|
||||
// index is empty (upgrade corner case, or a corrupted save),
|
||||
// the bundles are effectively unreachable — wipe them too
|
||||
// so a fresh publish happens.
|
||||
if (decoded.bundles.isNotEmpty() && decoded.eventIdToSlot.isEmpty()) {
|
||||
Log.w("KeyPackageRotationManager") {
|
||||
"Restored ${decoded.bundles.size} bundle(s) but no eventId→slot mapping — discarding, will republish"
|
||||
}
|
||||
try {
|
||||
store.delete()
|
||||
} catch (e: Exception) {
|
||||
Log.w("KeyPackageRotationManager", "Failed to delete stale snapshot: ${e.message}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
mutex.withLock {
|
||||
activeBundles.clear()
|
||||
activeBundles.putAll(decoded.bundles)
|
||||
@@ -146,13 +184,17 @@ class KeyPackageRotationManager(
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the persisted snapshot.
|
||||
* Decode the persisted snapshot. Returns null if the on-disk version
|
||||
* is older than the current [SNAPSHOT_VERSION] and should be discarded
|
||||
* by the caller (see [restoreFromStore] for the rationale).
|
||||
*/
|
||||
private fun decodeSnapshot(bytes: ByteArray): Snapshot {
|
||||
private fun decodeSnapshot(bytes: ByteArray): Snapshot? {
|
||||
val reader = TlsReader(bytes)
|
||||
val version = reader.readUint16()
|
||||
require(version == 1 || version == SNAPSHOT_VERSION) {
|
||||
"Unsupported KeyPackage snapshot version: $version"
|
||||
if (version != SNAPSHOT_VERSION) {
|
||||
// Older / unknown snapshot version — tell the caller to
|
||||
// discard it rather than loading partial state.
|
||||
return null
|
||||
}
|
||||
val numBundles = reader.readUint32().toInt()
|
||||
val bundles = mutableMapOf<String, KeyPackageBundle>()
|
||||
@@ -170,11 +212,8 @@ class KeyPackageRotationManager(
|
||||
repeat(numPending) {
|
||||
pending.add(reader.readOpaque2().decodeToString())
|
||||
}
|
||||
// eventId → slot map: only present in v2+. Older snapshots may
|
||||
// not have this section, in which case the map starts empty and
|
||||
// will be repopulated as KeyPackages are republished.
|
||||
val eventIdMap = mutableMapOf<String, String>()
|
||||
if (version >= SNAPSHOT_VERSION && reader.hasRemaining) {
|
||||
if (reader.hasRemaining) {
|
||||
val numEventIds = reader.readUint32().toInt()
|
||||
repeat(numEventIds) {
|
||||
val eventId = reader.readOpaque2().decodeToString()
|
||||
|
||||
Reference in New Issue
Block a user