diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt new file mode 100644 index 00000000..1a138c00 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt @@ -0,0 +1,207 @@ +package press.mantra.compose.managers + +import press.mantra.compose.extensions.toHex +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.XonlyPublicKey +import fr.acinq.bitcoin.crypto.frost.TweakCache +import fr.acinq.bitcoin.utils.Either + +/** + * Keys derived from a group's ChillDKG threshold key by FROST tweaks. + * + * A derived key here is a real key the group can sign with: each step applies an + * additive tweak, `K' = K + t·G`, which every signer can apply to their own share + * at signing time. The [TweakCache] is what carries that, and a signing session + * needs the cache these functions return -- not just the public key. + * + * Everything is a pure function of the threshold key and the path, so every + * member's device computes the same result with no agreement round, and nothing + * has to be stored. Rederive rather than persist. + * + * ### Why this is not BIP32, and why it needs no chain code + * + * The paths read like BIP32 and are walked the same way, index by index, but a + * BIP32 node is a key *and* a chain code and ChillDKG produces no chain code. + * BIP32 wants one only because it computes the tweak scalar for you: + * + * t = HMAC-SHA512(chaincode, serP(K_par) || ser32(i))[0:32] + * + * A FROST tweak takes that scalar as an input, so choosing it directly removes + * the chain code from the problem entirely. It also removes the trap BIP32 would + * bring here: with x-only keys there is no single obvious `serP(K_par)`, and two + * devices picking different parity conventions would silently derive different + * keys rather than fail. + * + * ### The security property this inherits + * + * Additive tweaking is exactly what non-hardened BIP32 does, and it carries the + * same weakness: `k' = k + t` with a publicly computable `t` inverts, so anyone + * who learns one derived private key recovers the threshold key and can sign as + * the group without any quorum at all. Hardened derivation is not available -- + * it needs the parent private key, which by construction nobody has. + * + * So the rule is: never reconstruct a derived key in the clear. Any path that + * could -- an export, a "reveal private key" screen, a test helper -- leaks the + * group key, not just the key it appears to expose. + */ +object SharedKeyDerivation { + /** + * The path the group's Marmot admin room is derived at. Arbitrary, and has to + * stay put: changing it orphans every room already created here, because the + * derived key *is* the room's id and how members find it. + */ + val MARMOT_ADMIN_GROUP_PATH: List = listOf(9420L, 0L, 0L) + + /** + * Domain separation for the tweak scalars. Versioned so a future scheme can + * coexist with keys already derived under this one, and distinct from any other + * use of the same threshold key. + */ + private const val TWEAK_TAG = "mantra/shared-key/tweak/v1" + + /** + * A key derived from the group's threshold key, and the cache needed to sign + * with it. + * + * [cache] is not an optimisation: a FROST signing session has to be created + * with the cache carrying the same tweaks, or the partial signatures aggregate + * to something that verifies against the wrong key. It holds no secrets and can + * be stored, though rederiving is simpler. + */ + data class Derived( + val cache: TweakCache, + val publicKey: XonlyPublicKey + ) { + /** The x-only key as 32-byte hex — the form nostr and Marmot both use. */ + val hex: HexKey get() = publicKey.value.toHex() + } + + /** + * The marker a derived room's description carries its path under. + * + * MIP-01's group data is a fixed TLS schema -- name, description, admins, + * relays, image fields, a disappearing-message timer -- with no room for a + * custom key. Inventing one would emit bytes other Marmot clients cannot + * decode, so the path rides in the description, which is the only free text + * MIP-01 offers. + * + * Worth writing down at all because the path is what rebuilds the [TweakCache] + * a signing session needs. Today it could be recomputed from + * [MARMOT_ADMIN_GROUP_PATH], but that only holds while the constant never + * changes; a room records the path it was actually made under, so a later + * scheme can coexist with rooms already created. + */ + private const val PATH_MARKER = "Shared key path: " + + /** `m/9420/0/0`. Hardened markers are absent on purpose -- see [parsePath]. */ + fun formatPath(path: List = MARMOT_ADMIN_GROUP_PATH): String = + path.joinToString(separator = "/", prefix = "m/") + + /** + * The path a derived room's description records, or null if it carries none. + * + * Rejects hardened indices rather than tolerating them: hardened derivation + * needs the parent private key, which in a threshold group nobody has, so a + * path containing one was never walked and nothing should act as if it was. + */ + fun parsePath(description: String?): List? { + val line = description + ?.lineSequence() + ?.firstOrNull { it.trimStart().startsWith(PATH_MARKER) } + ?: return null + + val path = line.trimStart().removePrefix(PATH_MARKER).trim() + if (!path.startsWith("m/")) return null + + return path.removePrefix("m/") + .split("/") + .map { segment -> segment.toLongOrNull() ?: return null } + .takeIf { it.isNotEmpty() } + } + + /** + * A room description that reads as prose and still records the path. + * + * One line of it, on its own line, so [parsePath] can find it without the rest + * of the description constraining what anybody writes there. + */ + fun describe(purpose: String, path: List = MARMOT_ADMIN_GROUP_PATH): String = + "$purpose\n\n$PATH_MARKER${formatPath(path)}" + + /** + * Walks [path] from the group's threshold key, applying one tweak per index. + * + * Each scalar commits to the key it is being applied to as well as the index, + * so the steps cannot be reordered or replayed at a different depth to reach the + * same key. x-only throughout, because the outputs are nostr keys. + * + * @throws IllegalArgumentException if the threshold key is not a valid point, or + * a tweak lands on one of the vanishingly rare invalid scalars — both of which + * mean the caller is holding something that is not a usable threshold key. + */ + fun derive( + thresholdPublicKey: HexKey, + path: List = MARMOT_ADMIN_GROUP_PATH + ): Derived { + val root = PublicKey(ByteVector(thresholdPublicKey.hexToByteArray())) + + var cache = TweakCache.create(root) + var current = XonlyPublicKey(root) + + path.forEach { index -> + val scalar = tweakScalar(current, index) + + when (val tweaked = cache.tweak(scalar, isXonly = true)) { + is Either.Left -> throw IllegalArgumentException( + "Could not derive $path from the group's key at index $index", + tweaked.value + ) + + is Either.Right -> { + cache = tweaked.value.first + current = tweaked.value.second + } + } + } + + return Derived(cache = cache, publicKey = current) + } + + /** + * The Marmot `nostrGroupId` for a room derived at [path]. + * + * Replaces the `RandomInstance.bytes(32)` a Marmot room is normally minted + * with. Marmot asks that the id be unpredictable to outsiders, which this is -- + * it is a public key derived from one the group has never published -- and it + * buys two things random cannot: every member can compute it without being told, + * and it is a key the group can sign as, so the room's id and the identity + * behind it are the same thing. + */ + fun marmotGroupId( + thresholdPublicKey: HexKey, + path: List = MARMOT_ADMIN_GROUP_PATH + ): HexKey = derive(thresholdPublicKey, path).hex + + /** + * The tweak for one step: binds the tag, the key being tweaked and the index, so + * the same index at a different point in the walk gives a different scalar. + */ + private fun tweakScalar(parent: XonlyPublicKey, index: Long): ByteVector32 = + ByteVector32( + Crypto.sha256( + TWEAK_TAG.encodeToByteArray() + + parent.value.toByteArray() + + byteArrayOf( + (index ushr 24).toByte(), + (index ushr 16).toByte(), + (index ushr 8).toByte(), + index.toByte() + ) + ) + ) +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index c32eb08f..10c10b9c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -9,17 +9,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -248,7 +251,11 @@ fun DkgRitualScreen( members = dkgRitualUIState.ritualMembers, hostKeyParticipants = dkgRitualUIState.hostKeyParticipants, round1Participants = dkgRitualUIState.round1Participants, - round2Participants = dkgRitualUIState.round2Participants + round2Participants = dkgRitualUIState.round2Participants, + isActionPending = isActionPending, + onCreateAdminGroup = { + dkgRitualViewModel.createAdminGroup(onNavigateToRoute) + } ) } } @@ -289,6 +296,8 @@ private fun RitualProgress( hostKeyParticipants: Set, round1Participants: Set, round2Participants: Set, + isActionPending: Boolean, + onCreateAdminGroup: () -> Unit, ) { val stage = session.stage val hostKeyCount = hostKeyParticipants.size @@ -435,6 +444,26 @@ private fun RitualProgress( text = "Your share of it is on this device only. Your wallet backup restores it — nobody else's share can.", style = MaterialTheme.typography.labelMedium ) + + // Offered to whoever ran the ceremony. Any member's device could + // derive the same room id and create it, but one of them has to go + // first, and the coordinator is the member the group already watched + // do the work. + if (session.isCoordinator()) { + Button( + onClick = { onCreateAdminGroup() }, + enabled = !isActionPending, + modifier = Modifier.fillMaxWidth() + ) { + if (isActionPending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Icon(Icons.Default.Groups, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "Create the #admins group") + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index 7717b30e..d61750cc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -13,6 +13,23 @@ import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.managers.ChillDkgRitualManager +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.Relays +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.Dispatchers.Main import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository @@ -216,6 +233,179 @@ class DkgRitualViewModel( } } + /** + * Opens the group's admin room: a Marmot/MLS chat whose every member is an + * admin, keyed on an id derived from the shared key. + * + * The room the ceremony ran in is NIP-17, where nobody administers anything. + * This gives the same people a room where every one of them can act, which is + * the shape a group that has just made a t-of-n key is asking for. + * + * Derived rather than random, unlike every other Marmot room. Every member's + * device can compute the id from the ceremony they all took part in, so the + * room is addressable without being announced, and two members racing to create + * it produce the same id instead of two rival rooms -- which is why this returns + * early to the existing room rather than minting a second one. + */ + fun createAdminGroup(onNavigateToRoute: (Route) -> Unit) { + if (isActionPending.value) return + + val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val thresholdPublicKey = loaded.session?.thresholdPublicKey ?: return + + val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + if (nostrPrivateKey == null) { + dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") + return + } + + val keyPair = KeyPair(privKey = nostrPrivateKey.value.toByteArray()) + val groupId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + // Everyone the ceremony was run with, this device included. These are the + // admins: the whole point of the room is that the members who hold shares of + // the key can all act in it. + val members = ChillDkgRitualManager.memberPublicKeys(loaded.localChatRoom) + val name = "${loaded.localChatRoom.chatRoom.subject ?: "Group"} (#admins)" + + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + // Derived ids make this reachable twice -- a second tap, or another + // member having got there first. Joining what exists beats minting a + // rival group on the same id. + val existing = chatRepository.getChatRoomByIdentifier(groupId) + if (existing != null) { + isActionPending.value = false + withContext(Main) { + onNavigateToRoute( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = existing.chatRoom.id, + relayHint = null + ) + ) + } + return@launch + } + + val relays = Relays.DefaultDMRelayList.map { it.url } + + // Built directly rather than through MarmotGroupData.bootstrap, which + // hardcodes a single admin. Baked into the epoch-0 GroupContext so later + // invitees get a populated group from their welcome instead of chasing a + // bootstrap commit that predates their membership. + val metadata = MarmotGroupData( + nostrGroupId = groupId, + name = name, + // Carries the derivation path. MIP-01 has no field for it, and the + // path is what rebuilds the TweakCache a signing session needs -- + // recomputable from the constant only for as long as the constant + // never changes. + description = SharedKeyDerivation.describe( + purpose = "Admins of ${loaded.localChatRoom.chatRoom.subject ?: "the group"}." + ), + adminPubkeys = members.toList(), + relays = relays + ) + + val signingKeyPair = Ed25519.generateKeyPair() + val group = MlsGroup.create(keyPair.pubKey, signingKeyPair.privateKey, listOf(metadata.toExtension())) + + // Keyed on the Marmot nostrGroupId, not MlsGroup's own groupId: they are + // unrelated 32-byte values, and every inbound path resolves rooms by the + // former. + val localChatRoom = chatRepository.getOrCreateChatRoom( + chatRoomId = groupId, + activeUserPublicKey = activeUserPublicKey, + relayHint = null, + defaultSubject = name, + description = metadata.description, + mlsGroupState = group.saveState().encodeTls().toHex() + ) + + if (localChatRoom == null) { + isActionPending.value = false + dkgRitualUIState = DkgRitualUIState.Error("Couldn't create the admin group. Please try again.") + return@launch + } + + val notAdded = inviteAdmins(groupId, members.filterNot { it == activeUserPublicKey }) + + isActionPending.value = false + + if (notAdded.isNotEmpty()) { + logger.w("Admin group $groupId created without ${notAdded.size} member(s): $notAdded") + } + + withContext(Main) { + onNavigateToRoute( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = localChatRoom.chatRoom.id, + relayHint = null + ) + ) + } + } + } + + /** + * Adds each member to the freshly created admin room, returning those who could + * not be added. + * + * A Marmot invite needs the invitee's published key package, so a member who has + * never published one cannot be added here and has to be invited later. That is + * reported rather than treated as failure: a room with most of the group in it + * is more useful than no room. + */ + private suspend fun inviteAdmins(groupId: String, peers: List): List { + val keyPackages = coroutineScope { + peers.map { publicKey -> + async { + publicKey to withTimeoutOrNull(KEY_PACKAGE_LOOKUP_TIMEOUT) { + chatRepository.observeMarmotKeyPackageForPublicKey(publicKey) + .filterNotNull() + .first() + } + } + }.awaitAll() + } + + val notAdded = mutableListOf() + + keyPackages.forEach { (publicKey, keyPackage) -> + if (keyPackage == null) { + logger.w("No key package for $publicKey; leaving them out of $groupId") + notAdded.add(publicKey) + return@forEach + } + + // Re-read between invites: each one advances the MLS epoch and persists + // new state, so a snapshot taken before the previous invite would build + // this commit on state the group has already left. + val localChatRoom = chatRepository.getChatRoomByIdentifier(groupId) + if (localChatRoom == null) { + logger.e("Admin group $groupId disappeared mid-invite") + notAdded.add(publicKey) + return@forEach + } + + runCatching { + chatRepository.inviteMember( + localChatRoom = localChatRoom, + peerPublicKey = publicKey, + peerKeyPackage = keyPackage + ) + }.onFailure { + logger.e("Failed to invite $publicKey to admin group $groupId", it) + notAdded.add(publicKey) + } + } + + return notAdded + } + override fun onCleared() { messageObserver?.cancel() super.onCleared() @@ -224,6 +414,9 @@ class DkgRitualViewModel( companion object { private const val TAG = "DkgRitualViewModel" + /** Matches SelectChatRoomTypeViewModel: relays are not always prompt. */ + private const val KEY_PACKAGE_LOOKUP_TIMEOUT = 10_000L + fun factory( chatRoomId: String, activeUserPublicKey: HexKey, diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt new file mode 100644 index 00000000..31e118da --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt @@ -0,0 +1,128 @@ +package press.mantra.compose.managers + +import press.mantra.compose.extensions.toHex +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.secp256k1.Hex +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The derivation every member's device has to agree on, run against real FROST + * tweaks. + * + * Nothing here checks arithmetic -- libsecp256k1 does that. What is checked is the + * property the group depends on: two devices holding the same threshold key and + * the same path arrive at the same key, without exchanging anything. If that ever + * stops being true, members create rival admin rooms and neither can see the + * other, so it is worth a test that fails loudly rather than a comment. + */ +class SharedKeyDerivationTest { + /** Stands in for a ceremony's output. Any valid point will do. */ + private val thresholdPublicKey = PrivateKey( + Hex.decode("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ).publicKey().value.toHex() + + @Test + fun `the same key and path always give the same room`() { + val first = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + val second = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + assertEquals(first, second) + } + + @Test + fun `a derived room id is a 32-byte x-only key`() { + val id = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + // Marmot wants 32 bytes for nostrGroupId and nostr wants 32 bytes for an + // x-only key. That these are the same length is what lets the room's id and + // the identity the group signs with be one value. + assertEquals(64, id.length) + assertTrue(id.all { it in "0123456789abcdef" }, "not lowercase hex: $id") + } + + @Test + fun `different paths give different keys`() { + val admins = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, listOf(9420L, 0L, 0L)) + val sibling = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, listOf(9420L, 0L, 1L)) + val other = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, listOf(9421L, 0L, 0L)) + + assertNotEquals(admins, sibling) + assertNotEquals(admins, other) + } + + @Test + fun `each step commits to the key it tweaks, so depth is not interchangeable`() { + // Index 0 applied once is not index 0 applied three times: the scalar binds + // the key being tweaked, so a shorter path cannot collide with a deeper one + // that happens to end on the same indices. + val shallow = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, listOf(0L)) + val deep = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, listOf(0L, 0L, 0L)) + + assertNotEquals(shallow, deep) + } + + @Test + fun `a different group key gives a different room`() { + val otherKey = PrivateKey( + Hex.decode("2badc0de0000000000000000000000000000000000000000000000000000005b") + ).publicKey().value.toHex() + + assertNotEquals( + SharedKeyDerivation.marmotGroupId(thresholdPublicKey), + SharedKeyDerivation.marmotGroupId(otherKey) + ) + } + + @Test + fun `a room description round-trips its path`() { + val description = SharedKeyDerivation.describe("Admins of Ubuntu Collective.") + + assertTrue(description.startsWith("Admins of Ubuntu Collective."), description) + assertEquals(SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, SharedKeyDerivation.parsePath(description)) + } + + @Test + fun `a path survives someone editing the rest of the description`() { + // The marker sits on its own line precisely so the prose around it is not + // load-bearing -- a member renaming or rewriting the description should not + // cost the group the record of how its key was derived. + val edited = "Totally different wording.\n\n" + + SharedKeyDerivation.describe("ignored").lines().last() + + assertEquals(SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, SharedKeyDerivation.parsePath(edited)) + } + + @Test + fun `descriptions without a path parse to null rather than a guess`() { + assertEquals(null, SharedKeyDerivation.parsePath(null)) + assertEquals(null, SharedKeyDerivation.parsePath("")) + assertEquals(null, SharedKeyDerivation.parsePath("Admins of something.")) + } + + @Test + fun `a hardened path is refused, not silently walked`() { + // Hardened derivation needs the parent private key, which nobody in a + // threshold group has. A path claiming one was never walked, so acting on it + // would mean deriving something different from what the room says. + assertEquals(null, SharedKeyDerivation.parsePath("Shared key path: m/9420'/0/0")) + } + + @Test + fun `formatted paths read the way they are written`() { + assertEquals("m/9420/0/0", SharedKeyDerivation.formatPath()) + assertEquals("m/9420/0/1", SharedKeyDerivation.formatPath(listOf(9420L, 0L, 1L))) + } + + @Test + fun `the cache tracks the same key the derivation returns`() { + // A signing session is created from the cache, not from the public key, so + // the two disagreeing would mean signatures verifying against something + // other than the room's id. + val derived = SharedKeyDerivation.derive(thresholdPublicKey) + + assertEquals(derived.publicKey, derived.cache.tweakedPublicKey) + } +}