Generates keypackages with 64 chars

This commit is contained in:
Vitor Pamplona
2026-04-20 12:00:31 -04:00
parent f71d8eebb8
commit 4f2f8e180e
4 changed files with 73 additions and 32 deletions
@@ -136,7 +136,6 @@ import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent
import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.hash
import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.experimental.profileGallery.mimeType
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
@@ -2211,23 +2210,13 @@ class Account(
} }
/** /**
* Check if a KeyPackage has been published, either locally generated * Check if a KeyPackage has been published in this session.
* in this session or found in the local cache from a previous session. * The d-tag is a randomly-generated value stored in the KeyPackageRotationManager's
* persisted snapshot, so there is no fixed address to query in the cache.
*/ */
suspend fun hasPublishedKeyPackage(): Boolean { suspend fun hasPublishedKeyPackage(): Boolean {
// Check in-memory bundles first (current session) val manager = marmotManager ?: return false
val manager = marmotManager return manager.hasActiveKeyPackages()
if (manager != null && manager.hasActiveKeyPackages()) return true
// Check local cache for our own kind:30443 events (from previous sessions / relay downloads)
val address =
com.vitorpamplona.quartz.nip01Core.core.Address(
KeyPackageEvent.KIND,
signer.pubKey,
KeyPackageUtils.PRIMARY_SLOT,
)
val note = cache.getAddressableNoteIfExists(address)
return note?.event != null
} }
/** /**
@@ -296,10 +296,11 @@ class MarmotManager(
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
suspend fun generateKeyPackageEvent( suspend fun generateKeyPackageEvent(
relays: List<NormalizedRelayUrl>, relays: List<NormalizedRelayUrl>,
dTagSlot: String = KeyPackageUtils.PRIMARY_SLOT, slotName: String = KeyPackageUtils.PRIMARY_SLOT,
): KeyPackageEvent { ): KeyPackageEvent {
val dTag = keyPackageRotationManager.getOrCreateSlotDTag(slotName)
val identity = signer.pubKey.hexToByteArray() val identity = signer.pubKey.hexToByteArray()
val bundle = keyPackageRotationManager.generateKeyPackage(identity, dTagSlot) val bundle = keyPackageRotationManager.generateKeyPackage(identity, dTag)
val keyPackageBytes = bundle.keyPackage.toTlsBytes() val keyPackageBytes = bundle.keyPackage.toTlsBytes()
val keyPackageBase64 = Base64.encode(keyPackageBytes) val keyPackageBase64 = Base64.encode(keyPackageBytes)
@@ -308,7 +309,7 @@ class MarmotManager(
val template = val template =
KeyPackageEvent.build( KeyPackageEvent.build(
keyPackageBase64 = keyPackageBase64, keyPackageBase64 = keyPackageBase64,
dTagSlot = dTagSlot, dTagSlot = dTag,
keyPackageRef = keyPackageRef, keyPackageRef = keyPackageRef,
relays = relays, relays = relays,
) )
@@ -317,7 +318,7 @@ class MarmotManager(
// Welcome receivers identify the consumed KeyPackage by its Nostr // Welcome receivers identify the consumed KeyPackage by its Nostr
// event id (the MIP-02 "e" tag), not by the MLS reference hash, so // event id (the MIP-02 "e" tag), not by the MLS reference hash, so
// remember the mapping right after we know the signed event id. // remember the mapping right after we know the signed event id.
keyPackageRotationManager.recordPublishedEventId(dTagSlot, signed.id) keyPackageRotationManager.recordPublishedEventId(dTag, signed.id)
return signed return signed
} }
@@ -62,6 +62,13 @@ class KeyPackageRotationManager(
private val activeBundles = mutableMapOf<String, KeyPackageBundle>() private val activeBundles = mutableMapOf<String, KeyPackageBundle>()
private val pendingRotations = mutableSetOf<String>() private val pendingRotations = mutableSetOf<String>()
/**
* Maps logical slot names (e.g., "primary") to their persisted random d-tag values.
* Per MIP-00, each slot gets a cryptographically random 64-char hex d-tag on first
* use, which is then reused for all rotations of that slot.
*/
private val namedSlotDTags = mutableMapOf<String, String>()
/** /**
* Maps the Nostr event id (kind:30443) of a published KeyPackage to the * Maps the Nostr event id (kind:30443) of a published KeyPackage to the
* d-tag slot whose bundle backs it. The welcome event references its * d-tag slot whose bundle backs it. The welcome event references its
@@ -136,10 +143,13 @@ class KeyPackageRotationManager(
pendingRotations.addAll(decoded.pending) pendingRotations.addAll(decoded.pending)
eventIdToSlot.clear() eventIdToSlot.clear()
eventIdToSlot.putAll(decoded.eventIdToSlot) eventIdToSlot.putAll(decoded.eventIdToSlot)
namedSlotDTags.clear()
namedSlotDTags.putAll(decoded.namedSlotDTags)
} }
Log.d("KeyPackageRotationManager") { Log.d("KeyPackageRotationManager") {
"Restored ${decoded.bundles.size} active KeyPackage bundle(s), " + "Restored ${decoded.bundles.size} active KeyPackage bundle(s), " +
"${decoded.pending.size} pending rotation, ${decoded.eventIdToSlot.size} eventId mapping(s)" "${decoded.pending.size} pending rotation, ${decoded.eventIdToSlot.size} eventId mapping(s), " +
"${decoded.namedSlotDTags.size} named slot d-tag(s)"
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w("KeyPackageRotationManager", "Failed to decode persisted KeyPackages: ${e.message}") Log.w("KeyPackageRotationManager", "Failed to decode persisted KeyPackages: ${e.message}")
@@ -150,6 +160,7 @@ class KeyPackageRotationManager(
val bundles: Map<String, KeyPackageBundle>, val bundles: Map<String, KeyPackageBundle>,
val pending: Set<String>, val pending: Set<String>,
val eventIdToSlot: Map<String, String>, val eventIdToSlot: Map<String, String>,
val namedSlotDTags: Map<String, String>,
) )
/** /**
@@ -180,6 +191,12 @@ class KeyPackageRotationManager(
writer.putOpaque2(eventId.encodeToByteArray()) writer.putOpaque2(eventId.encodeToByteArray())
writer.putOpaque2(slot.encodeToByteArray()) writer.putOpaque2(slot.encodeToByteArray())
} }
// named slot → actual random d-tag (added in v3)
writer.putUint32(namedSlotDTags.size.toLong())
for ((name, dTag) in namedSlotDTags) {
writer.putOpaque2(name.encodeToByteArray())
writer.putOpaque2(dTag.encodeToByteArray())
}
return writer.toByteArray() return writer.toByteArray()
} }
@@ -213,15 +230,22 @@ class KeyPackageRotationManager(
pending.add(reader.readOpaque2().decodeToString()) pending.add(reader.readOpaque2().decodeToString())
} }
val eventIdMap = mutableMapOf<String, String>() val eventIdMap = mutableMapOf<String, String>()
val numEventIds = reader.readUint32().toInt()
repeat(numEventIds) {
val eventId = reader.readOpaque2().decodeToString()
val slot = reader.readOpaque2().decodeToString()
eventIdMap[eventId] = slot
}
val namedSlots = mutableMapOf<String, String>()
if (reader.hasRemaining) { if (reader.hasRemaining) {
val numEventIds = reader.readUint32().toInt() val numNamed = reader.readUint32().toInt()
repeat(numEventIds) { repeat(numNamed) {
val eventId = reader.readOpaque2().decodeToString() val name = reader.readOpaque2().decodeToString()
val slot = reader.readOpaque2().decodeToString() val dTag = reader.readOpaque2().decodeToString()
eventIdMap[eventId] = slot namedSlots[name] = dTag
} }
} }
return Snapshot(bundles, pending, eventIdMap) return Snapshot(bundles, pending, eventIdMap, namedSlots)
} }
/** /**
@@ -238,11 +262,25 @@ class KeyPackageRotationManager(
} }
} }
/**
* Returns the actual d-tag (64-char random hex) for a logical slot name, generating
* and persisting a fresh random value on first call for that name.
*
* Per MIP-00: "Clients SHOULD generate a cryptographically random 32-byte hex string
* as the d tag value when first publishing a KeyPackage."
*/
suspend fun getOrCreateSlotDTag(logicalSlotName: String): String =
mutex.withLock {
namedSlotDTags.getOrPut(logicalSlotName) {
KeyPackageUtils.generateRandomDTag().also { persistUnlocked() }
}
}
/** /**
* Generate a new KeyPackage and its associated private bundle. * Generate a new KeyPackage and its associated private bundle.
* *
* @param identity the user's identity bytes (typically 32-byte Nostr pubkey) * @param identity the user's identity bytes (typically 32-byte Nostr pubkey)
* @param dTagSlot the d-tag slot for addressable replacement * @param dTagSlot the actual d-tag value (64-char hex) for addressable replacement
* @return a [KeyPackageBundle] containing the KeyPackage and all private keys * @return a [KeyPackageBundle] containing the KeyPackage and all private keys
*/ */
suspend fun generateKeyPackage( suspend fun generateKeyPackage(
@@ -480,7 +518,8 @@ class KeyPackageRotationManager(
* On-disk snapshot format version for [KeyPackageBundleStore]. * On-disk snapshot format version for [KeyPackageBundleStore].
* v1: bundles + pendingRotations * v1: bundles + pendingRotations
* v2: + eventIdToSlot map (so welcome lookup by Nostr event id works) * v2: + eventIdToSlot map (so welcome lookup by Nostr event id works)
* v3: + namedSlotDTags map (per MIP-00, d-tags are random 64-char hex, persisted here)
*/ */
private const val SNAPSHOT_VERSION = 2 private const val SNAPSHOT_VERSION = 3
} }
} }
@@ -21,8 +21,10 @@
package com.vitorpamplona.quartz.marmot.mip00KeyPackages package com.vitorpamplona.quartz.marmot.mip00KeyPackages
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
@@ -41,12 +43,22 @@ object KeyPackageUtils {
/** Current addressable KeyPackage kind */ /** Current addressable KeyPackage kind */
const val CURRENT_KIND = KeyPackageEvent.KIND // 30443 const val CURRENT_KIND = KeyPackageEvent.KIND // 30443
/** Default d-tag slot for primary KeyPackage */
const val PRIMARY_SLOT = "0"
/** Maximum number of d-tag slots a user should maintain */ /** Maximum number of d-tag slots a user should maintain */
const val MAX_SLOTS = 10 const val MAX_SLOTS = 10
/**
* Logical name for the primary KeyPackage slot.
* This is NOT the actual d-tag value the actual d-tag is a randomly
* generated 64-char hex string stored persistently in [KeyPackageRotationManager].
*/
const val PRIMARY_SLOT = "primary"
/**
* Generates a cryptographically random d-tag value per MIP-00:
* a 32-byte (64 hex char) random identifier for a KeyPackage slot.
*/
fun generateRandomDTag(): String = MlsCryptoProvider.randomBytes(32).toHexKey()
/** /**
* Selects the best KeyPackage from a set of candidates for a given user. * Selects the best KeyPackage from a set of candidates for a given user.
* *