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].
|
* Restore previously persisted bundles + rotation state from [store].
|
||||||
* Call once at startup before any other use of this manager.
|
* Call once at startup before any other use of this manager.
|
||||||
* Safe to call when no store is configured (no-op in that case).
|
* 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() {
|
suspend fun restoreFromStore() {
|
||||||
val store = store ?: return
|
val store = store ?: return
|
||||||
@@ -91,6 +99,36 @@ class KeyPackageRotationManager(
|
|||||||
} ?: return
|
} ?: return
|
||||||
try {
|
try {
|
||||||
val decoded = decodeSnapshot(bytes)
|
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 {
|
mutex.withLock {
|
||||||
activeBundles.clear()
|
activeBundles.clear()
|
||||||
activeBundles.putAll(decoded.bundles)
|
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 reader = TlsReader(bytes)
|
||||||
val version = reader.readUint16()
|
val version = reader.readUint16()
|
||||||
require(version == 1 || version == SNAPSHOT_VERSION) {
|
if (version != SNAPSHOT_VERSION) {
|
||||||
"Unsupported KeyPackage snapshot version: $version"
|
// Older / unknown snapshot version — tell the caller to
|
||||||
|
// discard it rather than loading partial state.
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
val numBundles = reader.readUint32().toInt()
|
val numBundles = reader.readUint32().toInt()
|
||||||
val bundles = mutableMapOf<String, KeyPackageBundle>()
|
val bundles = mutableMapOf<String, KeyPackageBundle>()
|
||||||
@@ -170,11 +212,8 @@ class KeyPackageRotationManager(
|
|||||||
repeat(numPending) {
|
repeat(numPending) {
|
||||||
pending.add(reader.readOpaque2().decodeToString())
|
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>()
|
val eventIdMap = mutableMapOf<String, String>()
|
||||||
if (version >= SNAPSHOT_VERSION && reader.hasRemaining) {
|
if (reader.hasRemaining) {
|
||||||
val numEventIds = reader.readUint32().toInt()
|
val numEventIds = reader.readUint32().toInt()
|
||||||
repeat(numEventIds) {
|
repeat(numEventIds) {
|
||||||
val eventId = reader.readOpaque2().decodeToString()
|
val eventId = reader.readOpaque2().decodeToString()
|
||||||
|
|||||||
Reference in New Issue
Block a user