refactor: carry signing on marmot inner events, not gift wraps
A signing message is now an ordinary Marmot inner event: queued with a
null marmotGroupEventId, picked up by the outbound pipeline, MLS-encrypted
and broadcast as one kind:445 for the room. Inbound it arrives through
ChatMessage.fromGroupEventResult like every other inner event, and is
dispatched from NostrDao rather than from the gift-wrap branch.
The ceremony keeps NIP-17 because it has no choice: its participants are
not yet a Marmot group, and its purpose is to produce the key one would
be keyed on. Signing has that solved for it, so it was paying for
addressing it does not need -- a gift wrap is sealed once per recipient,
so every message cost one wrap per member, and every message had to name
the whole group in p-tags. A group event is encrypted to the group once.
That also removes a small dishonesty. The signer set is supposed to come
from the ceremony; carrying p-tags meant each message also asserted a
membership list, and two sources for one fact is one too many. Now who
can read a message is the MLS tree's business and who may sign is the
ceremony's.
Which room follows from the transport. A ceremony runs in a NIP-17 room
-- every member an equal admin, no MLS tree to be outside of -- and a
group event needs an MLS one, so signing cannot happen where the ceremony
did. It happens in the #admins room, which is the right venue anyway: it
already exists after a ceremony, its membership is exactly the share
holders, and its id *is* the key, derived by
SharedKeyDerivation.marmotGroupId.
So completedKey rederives rather than reading a column: a room cannot be
pointed at a key it was not derived from. Receivers were already
independent of this, naming their key in the proposal's frost_key tag and
looking it up locally.
Mechanical consequences:
- processSigningPayload, acceptProposal, record and isFromCoordinator
take the decrypted Event instead of a GiftWrapPayload.
- replayStoredMessages reads MarmotInnerEvent rows, via a new
getByChatRoomAndKinds, and rebuilds the rumor from the row's own
columns.
- applyInnerEvent returns null for the signing kinds. They are the
manager's, and it writes transcript lines naming who did what, so an
"unsupported" row would be a second and worse account of the same
thing.
- DkgSessionDao gains getKeyHoldingSessions for the derivation match.
The kind comment is rewritten rather than kept. 3032x was chosen to clear
the DKG, which now shares no transport with signing and cannot clash with
it; what it actually has to clear is the nip30303 document kinds, which
run 30300-30312 and are dispatched by the same inbound path. It still
does. The DKG's own overlap with those numbers is noted there as the
routing accident it is, so nothing added later leans on it.
No schema change: both tables and the columns landed in v6 with the
previous commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,16 @@ interface DkgSessionDao {
|
||||
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
/**
|
||||
* Every ceremony this device came out of holding a share, newest first.
|
||||
*
|
||||
* Signing happens in the #admins room, whose id is derived from the key
|
||||
* rather than from the room the ceremony ran in, so the key is found by
|
||||
* matching that derivation rather than by a stored room id.
|
||||
*/
|
||||
@Query("SELECT * FROM DkgSession WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL ORDER BY createdAt DESC")
|
||||
suspend fun getKeyHoldingSessions(): List<DkgSession>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(dkgSession: DkgSession)
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ interface MarmotInnerEventDao {
|
||||
@Upsert
|
||||
suspend fun upsert(marmotInnerEvent: MarmotInnerEvent)
|
||||
|
||||
/**
|
||||
* A room's inner events of the given kinds, oldest first.
|
||||
*
|
||||
* Used to replay a protocol backlog: a session's messages are stored as they
|
||||
* decrypt, but one that arrives before the proposal opening its session has
|
||||
* nowhere to be filed at the time.
|
||||
*/
|
||||
@Query("SELECT * FROM MarmotInnerEvent WHERE chatRoomId = :chatRoomId AND kind IN (:kinds) ORDER BY createdAt ASC")
|
||||
suspend fun getByChatRoomAndKinds(chatRoomId: String, kinds: List<Int>): List<MarmotInnerEvent>
|
||||
|
||||
@Query("DELETE FROM MarmotInnerEvent WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
@@ -453,6 +454,25 @@ abstract class NostrDao(
|
||||
chatMessage
|
||||
)
|
||||
}
|
||||
|
||||
// A FROST signing message for this group. Driven from
|
||||
// here rather than from ChatMessage because the
|
||||
// manager needs the room to publish its own replies
|
||||
// into, and because it writes its transcript lines
|
||||
// itself. The manager is idempotent, so a redelivered
|
||||
// message re-runs a step it has already taken.
|
||||
if (groupEventResult is GroupEventResult.ApplicationMessage) {
|
||||
Event.fromJsonOrNull(groupEventResult.innerEventJson)
|
||||
?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) }
|
||||
?.let { innerEvent ->
|
||||
FrostSigningManager.processSigningPayload(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
innerEvent = innerEvent,
|
||||
userPublicKey = activeKeyPair.pubKey.toHex()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw MarmotNotMemberOfChatGroupException("We are not a member of the chat room ${localChatRoom.chatRoom.id}")
|
||||
@@ -962,29 +982,6 @@ abstract class NostrDao(
|
||||
nostrPrivateKey = activeKeyPair.privKey!!
|
||||
)
|
||||
}
|
||||
} else if (FrostSigningEvents.isFrostSigningKind(decryptedGiftWrapPayload.kind)) {
|
||||
// A FROST signing session message for one of our
|
||||
// NIP-17 groups. Same reasoning as the ritual above:
|
||||
// the manager is idempotent, and the room is created
|
||||
// on demand because membership is the payload's
|
||||
// p-tags either way.
|
||||
val localChatRoom = getOrCreateNip17ChatRoom(
|
||||
decryptedGiftWrapPayload = decryptedGiftWrapPayload,
|
||||
activeKeyPair = activeKeyPair,
|
||||
nostrEventId = nostrEvent.id,
|
||||
relayURL = relayURL
|
||||
)
|
||||
|
||||
if (localChatRoom == null) {
|
||||
logger.w("FROST payload for unknown chat room ${decryptedGiftWrapPayload.chatRoomId}")
|
||||
} else {
|
||||
FrostSigningManager.processSigningPayload(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
giftWrapPayload = decryptedGiftWrapPayload,
|
||||
userPublicKey = activeKeyPair.pubKey.toHex()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.w("Unsupported event: $decryptedGiftWrapPayload")
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
@@ -684,6 +685,12 @@ data class ChatMessage(
|
||||
)
|
||||
}
|
||||
}
|
||||
// A signing session's own protocol messages. FrostSigningManager
|
||||
// applies them and writes its own transcript lines naming who did
|
||||
// what, so an "unsupported" row here would be a second, worse
|
||||
// account of the same thing.
|
||||
in FrostSigningEvents.ALL -> null
|
||||
|
||||
else -> {
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
|
||||
@@ -7,8 +7,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
@@ -28,7 +26,7 @@ import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.GiftWrapPayload
|
||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
@@ -37,15 +35,23 @@ import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
|
||||
/**
|
||||
* Signs a nostr event with a group's FROST threshold key, over a NIP-17 group.
|
||||
* Signs a nostr event with a group's FROST threshold key, in its #admins room.
|
||||
*
|
||||
* The shape is [ChillDkgRitualManager]'s, deliberately: the member who proposes
|
||||
* a signature coordinates it, every protocol message travels as a gift-wrapped
|
||||
* rumor on the kinds in [FrostSigningEvents], each inbound message is persisted
|
||||
* and then the session is asked whether it can move, and every step is
|
||||
* recomputed from stored inputs so a device killed mid-round resumes on the
|
||||
* next message. What that manager's own notes say about being message-driven
|
||||
* applies here unchanged.
|
||||
* a signature coordinates it, protocol messages travel on the kinds in
|
||||
* [FrostSigningEvents], each inbound message is persisted and then the session
|
||||
* is asked whether it can move, and every step is recomputed from stored inputs
|
||||
* so a device killed mid-round resumes on the next message. What that manager's
|
||||
* own notes say about being message-driven applies here unchanged.
|
||||
*
|
||||
* The transport is not the same one. A ceremony runs over NIP-17 because it has
|
||||
* to: its participants are not yet a Marmot group, and its whole purpose is to
|
||||
* produce the key one would be keyed on. Signing has the opposite problem
|
||||
* solved for it -- the #admins room already exists, its membership is exactly
|
||||
* the share holders, and its id is derived from the key -- so a signing message
|
||||
* is an ordinary Marmot inner event and needs no addressing of its own. One
|
||||
* encrypted group event reaches everyone, rather than one sealed wrap per
|
||||
* member per message.
|
||||
*
|
||||
* Three things are genuinely different, and each of them is why this is a
|
||||
* separate manager rather than another branch of that one.
|
||||
@@ -148,43 +154,42 @@ object FrostSigningManager {
|
||||
suspend fun processSigningPayload(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
giftWrapPayload: GiftWrapPayload,
|
||||
innerEvent: Event,
|
||||
userPublicKey: HexKey
|
||||
) {
|
||||
val sessionId = FrostSigningEvents.parseSessionId(giftWrapPayload.tags)
|
||||
val sessionId = FrostSigningEvents.parseSessionId(innerEvent.tags)
|
||||
if (sessionId == null) {
|
||||
logger.w("FROST payload ${giftWrapPayload.id} has no session tag; dropping")
|
||||
logger.w("FROST payload ${innerEvent.id} has no session tag; dropping")
|
||||
return
|
||||
}
|
||||
|
||||
val session = if (giftWrapPayload.kind == FrostSigningEvents.PROPOSAL) {
|
||||
val session = if (innerEvent.kind == FrostSigningEvents.PROPOSAL) {
|
||||
acceptProposal(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
giftWrapPayload = giftWrapPayload,
|
||||
innerEvent = innerEvent,
|
||||
sessionId = sessionId,
|
||||
userPublicKey = userPublicKey
|
||||
)
|
||||
} else {
|
||||
// Not knowing the session is normal: gift wraps carry a randomised
|
||||
// created_at and relays hand them back in no particular order, so a
|
||||
// nonce routinely lands before the proposal that asks for it. The
|
||||
// payload is already stored, and [acceptProposal] replays it once the
|
||||
// proposal turns up.
|
||||
// Not knowing the session is normal: a member catching up applies a
|
||||
// group's backlog in whatever order the epochs decrypt, so a nonce can
|
||||
// land before the proposal that asks for it. The inner event is already
|
||||
// stored, and [acceptProposal] replays it once the proposal turns up.
|
||||
database.frostSigningSessionDao().getSessionById(sessionId)
|
||||
}
|
||||
|
||||
if (session == null) {
|
||||
logger.i("No signing session $sessionId for kind ${giftWrapPayload.kind}; leaving it stored")
|
||||
logger.i("No signing session $sessionId for kind ${innerEvent.kind}; leaving it stored")
|
||||
return
|
||||
}
|
||||
|
||||
if (session.stage == FrostSigningStage.FAILED) {
|
||||
logger.i("Session $sessionId already failed; ignoring kind ${giftWrapPayload.kind}")
|
||||
logger.i("Session $sessionId already failed; ignoring kind ${innerEvent.kind}")
|
||||
return
|
||||
}
|
||||
|
||||
if (!record(database, session, giftWrapPayload)) return
|
||||
if (!record(database, session, innerEvent)) return
|
||||
|
||||
advance(database, localChatRoom, session.id)
|
||||
}
|
||||
@@ -200,7 +205,7 @@ object FrostSigningManager {
|
||||
private suspend fun acceptProposal(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
giftWrapPayload: GiftWrapPayload,
|
||||
innerEvent: Event,
|
||||
sessionId: String,
|
||||
userPublicKey: HexKey
|
||||
): FrostSigningSession? {
|
||||
@@ -209,7 +214,7 @@ object FrostSigningManager {
|
||||
// under the same id carrying a different event is either a mistake or
|
||||
// an attempt to get two signatures out of one secret nonce, which is
|
||||
// how a share is extracted -- so it is refused, not applied.
|
||||
val proposed = Event.fromJsonOrNull(giftWrapPayload.content)
|
||||
val proposed = Event.fromJsonOrNull(innerEvent.content)
|
||||
if (proposed != null && proposed.id != existing.eventId) {
|
||||
logger.w(
|
||||
"Session $sessionId re-proposed with event ${proposed.id}, " +
|
||||
@@ -219,7 +224,7 @@ object FrostSigningManager {
|
||||
return existing
|
||||
}
|
||||
|
||||
val dkgSessionId = FrostSigningEvents.parseKey(giftWrapPayload.tags)
|
||||
val dkgSessionId = FrostSigningEvents.parseKey(innerEvent.tags)
|
||||
if (dkgSessionId == null) {
|
||||
logger.w("Signing proposal $sessionId names no key; dropping")
|
||||
return null
|
||||
@@ -237,7 +242,7 @@ object FrostSigningManager {
|
||||
return null
|
||||
}
|
||||
|
||||
val proposed = Event.fromJsonOrNull(giftWrapPayload.content)
|
||||
val proposed = Event.fromJsonOrNull(innerEvent.content)
|
||||
if (proposed == null) {
|
||||
logger.w("Signing proposal $sessionId does not carry an event; dropping")
|
||||
return null
|
||||
@@ -268,7 +273,7 @@ object FrostSigningManager {
|
||||
// Whoever proposes coordinates. Aggregating nonces and partial
|
||||
// signatures gives no power over the outcome -- a wrong aggregate
|
||||
// produces a signature that does not verify, not a forged one.
|
||||
coordinatorPublicKey = giftWrapPayload.publicKey,
|
||||
coordinatorPublicKey = innerEvent.pubKey,
|
||||
userPublicKey = userPublicKey,
|
||||
dkgSessionId = key.id,
|
||||
threshold = key.threshold,
|
||||
@@ -300,20 +305,30 @@ object FrostSigningManager {
|
||||
database: MantraDatabase,
|
||||
session: FrostSigningSession
|
||||
) {
|
||||
val stored = database.giftWrapPayloadDao().getByChatRoomAndKinds(
|
||||
val stored = database.marmotInnerEventDao().getByChatRoomAndKinds(
|
||||
chatRoomId = session.chatRoomId,
|
||||
kinds = FrostSigningEvents.ALL.toList()
|
||||
).filter { payload ->
|
||||
payload.kind != FrostSigningEvents.PROPOSAL &&
|
||||
FrostSigningEvents.parseSessionId(payload.tags) == session.id
|
||||
).filter { stored ->
|
||||
stored.kind != FrostSigningEvents.PROPOSAL &&
|
||||
FrostSigningEvents.parseSessionId(stored.tags) == session.id
|
||||
}.map { stored ->
|
||||
Event(
|
||||
id = stored.id,
|
||||
pubKey = stored.publicKey,
|
||||
createdAt = stored.createdAt.epochSeconds,
|
||||
kind = stored.kind,
|
||||
tags = stored.tags,
|
||||
content = stored.content,
|
||||
sig = ""
|
||||
)
|
||||
}
|
||||
|
||||
if (stored.isEmpty()) return
|
||||
|
||||
logger.i("Replaying ${stored.size} stored message(s) for signing session ${session.id}")
|
||||
|
||||
stored.forEach { payload ->
|
||||
if (!record(database, session, payload)) return
|
||||
stored.forEach { innerEvent ->
|
||||
if (!record(database, session, innerEvent)) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,34 +339,34 @@ object FrostSigningManager {
|
||||
private suspend fun record(
|
||||
database: MantraDatabase,
|
||||
session: FrostSigningSession,
|
||||
giftWrapPayload: GiftWrapPayload
|
||||
innerEvent: Event
|
||||
): Boolean {
|
||||
when (giftWrapPayload.kind) {
|
||||
when (innerEvent.kind) {
|
||||
FrostSigningEvents.PROPOSAL -> Unit // handled by acceptProposal
|
||||
|
||||
FrostSigningEvents.NONCE,
|
||||
FrostSigningEvents.PARTIAL_SIGNATURE -> {
|
||||
val known = database.frostSigningSessionDao()
|
||||
.getMessage(session.id, giftWrapPayload.kind, giftWrapPayload.publicKey) != null
|
||||
.getMessage(session.id, innerEvent.kind, innerEvent.pubKey) != null
|
||||
|
||||
database.frostSigningSessionDao().upsert(
|
||||
FrostSignerMessage(
|
||||
sessionId = session.id,
|
||||
signerPublicKey = giftWrapPayload.publicKey,
|
||||
kind = giftWrapPayload.kind,
|
||||
payload = giftWrapPayload.content
|
||||
signerPublicKey = innerEvent.pubKey,
|
||||
kind = innerEvent.kind,
|
||||
payload = innerEvent.content
|
||||
)
|
||||
)
|
||||
|
||||
if (!known) {
|
||||
announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey)
|
||||
announceStep(database, session, innerEvent.kind, innerEvent.pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
FrostSigningEvents.SIGNER_SET -> {
|
||||
if (!isFromCoordinator(session, giftWrapPayload)) return true
|
||||
if (!isFromCoordinator(session, innerEvent)) return true
|
||||
|
||||
val signerIds = FrostSigningEvents.parseSignerIds(giftWrapPayload.tags)
|
||||
val signerIds = FrostSigningEvents.parseSignerIds(innerEvent.tags)
|
||||
if (signerIds == null) {
|
||||
logger.w("Session ${session.id}: signer set carries no ids; ignoring")
|
||||
return true
|
||||
@@ -368,7 +383,7 @@ object FrostSigningManager {
|
||||
update(database, session) { current ->
|
||||
if (current.aggregatedNonce == null) {
|
||||
current.copy(
|
||||
aggregatedNonce = giftWrapPayload.content,
|
||||
aggregatedNonce = innerEvent.content,
|
||||
signerIds = signerIds.joinToString(",")
|
||||
)
|
||||
} else {
|
||||
@@ -377,25 +392,25 @@ object FrostSigningManager {
|
||||
}
|
||||
|
||||
if (!known) {
|
||||
announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey)
|
||||
announceStep(database, session, innerEvent.kind, innerEvent.pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
FrostSigningEvents.SIGNATURE -> {
|
||||
if (!isFromCoordinator(session, giftWrapPayload)) return true
|
||||
if (!isFromCoordinator(session, innerEvent)) return true
|
||||
|
||||
val known = current(database, session).signature != null
|
||||
|
||||
update(database, session) { current ->
|
||||
if (current.signature == null) {
|
||||
current.copy(signature = giftWrapPayload.content)
|
||||
current.copy(signature = innerEvent.content)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
if (!known) {
|
||||
announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey)
|
||||
announceStep(database, session, innerEvent.kind, innerEvent.pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,8 +418,8 @@ object FrostSigningManager {
|
||||
fail(
|
||||
database = database,
|
||||
session = session,
|
||||
reason = "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}",
|
||||
culprit = giftWrapPayload.publicKey
|
||||
reason = "Abandoned by ${innerEvent.pubKey.take(8)}: ${innerEvent.content}",
|
||||
culprit = innerEvent.pubKey
|
||||
)
|
||||
return false
|
||||
}
|
||||
@@ -415,13 +430,13 @@ object FrostSigningManager {
|
||||
|
||||
private fun isFromCoordinator(
|
||||
session: FrostSigningSession,
|
||||
giftWrapPayload: GiftWrapPayload
|
||||
innerEvent: Event
|
||||
): Boolean {
|
||||
if (giftWrapPayload.publicKey == session.coordinatorPublicKey) return true
|
||||
if (innerEvent.pubKey == session.coordinatorPublicKey) return true
|
||||
|
||||
logger.w(
|
||||
"Session ${session.id}: kind ${giftWrapPayload.kind} from " +
|
||||
"${giftWrapPayload.publicKey.take(8)}, who is not the coordinator; ignoring"
|
||||
"Session ${session.id}: kind ${innerEvent.kind} from " +
|
||||
"${innerEvent.pubKey.take(8)}, who is not the coordinator; ignoring"
|
||||
)
|
||||
return false
|
||||
}
|
||||
@@ -724,15 +739,34 @@ object FrostSigningManager {
|
||||
): Int? = signerIds(database, key)[member]
|
||||
|
||||
/**
|
||||
* The group's usable key, or null when it has none.
|
||||
* The key a room signs with, or null when it has none.
|
||||
*
|
||||
* A group can have run more than one ceremony; the newest completed one is
|
||||
* the live key, matching what the shared-key screen shows.
|
||||
* Signing runs in the #admins room, which is not where the ceremony ran. A
|
||||
* ceremony needs a NIP-17 group -- every member an equal admin, no MLS tree
|
||||
* to be outside of -- while a group event needs an MLS one, so the two
|
||||
* cannot be the same room.
|
||||
*
|
||||
* They are still bound together, and by construction rather than by a
|
||||
* column: the #admins room's id *is* the key, derived from it by
|
||||
* [SharedKeyDerivation.marmotGroupId]. Rederiving is what finds the key
|
||||
* here, which means a room cannot be pointed at a key it was not derived
|
||||
* from.
|
||||
*
|
||||
* Falls back to a ceremony held in this very room, which is not how the app
|
||||
* wires things today but costs one lookup to keep honest.
|
||||
*/
|
||||
suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? =
|
||||
database.dkgSessionDao()
|
||||
suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? {
|
||||
database.dkgSessionDao().getKeyHoldingSessions().firstOrNull { session ->
|
||||
session.stage == DkgRitualStage.COMPLETE &&
|
||||
session.thresholdPublicKey?.let {
|
||||
SharedKeyDerivation.marmotGroupId(it) == chatRoomId
|
||||
} == true
|
||||
}?.let { return it }
|
||||
|
||||
return database.dkgSessionDao()
|
||||
.getLatestSessionForChatRoom(chatRoomId)
|
||||
?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null }
|
||||
}
|
||||
|
||||
/** Whether this group can sign at all, read by the UI so it offers nothing that would fail. */
|
||||
suspend fun canSign(database: MantraDatabase, chatRoomId: String): Boolean =
|
||||
@@ -1082,9 +1116,16 @@ object FrostSigningManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a signing message as a gift-wrap payload. `NotaryViewModel` picks it
|
||||
* up, seals a copy per participant and broadcasts — the same path chat
|
||||
* messages take, which is why this needs no transport of its own.
|
||||
* Queues a signing message as an unprocessed marmot inner event.
|
||||
* `NotaryViewModel` picks it up, MLS-encrypts it and broadcasts it as a
|
||||
* kind:445 for the room — the same path every other event in a Marmot group
|
||||
* takes, which is why this needs no transport of its own.
|
||||
*
|
||||
* No p-tags. A gift wrap is addressed and sealed once per recipient, so the
|
||||
* ceremony has to name everybody on every message; a group event is
|
||||
* encrypted to the group, and who is in it is the MLS tree's business rather
|
||||
* than the message's. That also means the signer set genuinely comes from
|
||||
* the ceremony rather than from whoever happened to be tagged.
|
||||
*/
|
||||
private suspend fun broadcast(
|
||||
database: MantraDatabase,
|
||||
@@ -1095,40 +1136,31 @@ object FrostSigningManager {
|
||||
includeKey: Boolean = false,
|
||||
signerIds: List<Int>? = null
|
||||
) {
|
||||
val receiverTags = localChatRoom.localParticipants
|
||||
.distinctBy { it.participant.participantPublicKey }
|
||||
.filter { it.participant.participantPublicKey != session.userPublicKey }
|
||||
.map { localParticipant ->
|
||||
PTag.assemble(
|
||||
localParticipant.participant.participantPublicKey,
|
||||
localParticipant.participant.relayHint?.let { NormalizedRelayUrl(it) }
|
||||
)
|
||||
}
|
||||
|
||||
val tags = receiverTags.toTypedArray() + FrostSigningEvents.assembleTags(
|
||||
val tags = FrostSigningEvents.assembleTags(
|
||||
sessionId = session.id,
|
||||
dkgSessionId = if (includeKey) session.dkgSessionId else null,
|
||||
signerIds = signerIds
|
||||
)
|
||||
|
||||
val createdAt = Clock.System.now().epochSeconds
|
||||
val giftWrapPayloadId = EventHasher.hashId(
|
||||
pubKey = session.userPublicKey,
|
||||
createdAt = createdAt,
|
||||
tags = tags,
|
||||
content = content,
|
||||
kind = kind
|
||||
)
|
||||
|
||||
database.giftWrapPayloadDao().upsert(
|
||||
GiftWrapPayload(
|
||||
id = giftWrapPayloadId,
|
||||
database.marmotInnerEventDao().upsert(
|
||||
MarmotInnerEvent(
|
||||
// The rumor id the outbound pipeline will recompute from these
|
||||
// same fields when it assembles the event to encrypt.
|
||||
id = EventHasher.hashId(
|
||||
pubKey = session.userPublicKey,
|
||||
createdAt = createdAt,
|
||||
tags = tags,
|
||||
content = content,
|
||||
kind = kind
|
||||
),
|
||||
publicKey = session.userPublicKey,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
createdAt = Instant.fromEpochSeconds(createdAt),
|
||||
tags = tags,
|
||||
content = content,
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
publicKey = session.userPublicKey
|
||||
chatRoomId = localChatRoom.chatRoom.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
|
||||
/**
|
||||
* The nostr kinds a FROST signing session is carried on.
|
||||
*
|
||||
* Like the ChillDKG kinds these are **rumor** kinds: they only ever exist
|
||||
* inside a NIP-17 gift wrap addressed to the group, so no relay sees them
|
||||
* unencrypted and the replaceable semantics normally implied by the 3xxxx range
|
||||
* never apply.
|
||||
* These are **rumor** kinds: they only ever exist inside a Marmot group event,
|
||||
* MLS-encrypted to the group and then wrapped again under the group's exporter
|
||||
* secret, so no relay sees them and the replaceable semantics normally implied
|
||||
* by the 3xxxx range never apply.
|
||||
*
|
||||
* Who talks to whom, in order:
|
||||
*
|
||||
@@ -24,18 +24,21 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
|
||||
* anyone --[ 30325 failure ]-> everyone abandon + blame
|
||||
* ```
|
||||
*
|
||||
* ### Why 3032x and not 3031x
|
||||
* ### Why 3032x
|
||||
*
|
||||
* The DKG kinds run 30310-30316 and the nip30303 document kinds run 30300
|
||||
* upwards; those two have already met at 30310 and 30311, and
|
||||
* [press.mantra.compose.nostr.nip30303.SubmissionEvent] sits on 30312, which is
|
||||
* also the DKG's round-1 kind. They are kept apart today only by travelling on
|
||||
* different transports -- documents inside Marmot group events, rituals inside
|
||||
* NIP-17 wraps -- which is luck rather than design.
|
||||
* These share the inner-event space with the nip30303 document kinds, which run
|
||||
* 30300 up to [press.mantra.compose.nostr.nip30303.SubmissionEvent] at 30312 --
|
||||
* the same space, because both are Marmot inner events and both are dispatched
|
||||
* on kind by the same inbound path. Starting at 30320 leaves that family room to
|
||||
* grow into.
|
||||
*
|
||||
* Signing runs on the same transport as the DKG and in the same rooms, so it
|
||||
* starts at 30320 with a deliberate gap. Anything added to either family has
|
||||
* room to grow without a second accident.
|
||||
* The DKG's 30310-30316 look like a clash and are not: those exist only inside
|
||||
* NIP-17 gift wraps, and nothing reads a kind across both transports. It is
|
||||
* worth knowing that the numbers already overlap there -- the DKG's proposal and
|
||||
* host-key kinds sit on 30310 and 30311 alongside two nip30303 kinds, and its
|
||||
* round-1 kind is 30312, alongside SubmissionEvent -- because that separation is
|
||||
* an accident of routing rather than a decision, and the next family added
|
||||
* should not rely on it.
|
||||
*/
|
||||
object FrostSigningEvents {
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user