From 28b93132a3ebf2d12f9cd46871f1b63aa9c1b7e9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 17:16:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(concord):=20read=20+=20render=20CORD-02=20?= =?UTF-8?q?=C2=A76=20encrypted=20community=20icon/banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord community icons never showed (robohash instead), and the community name silently fell back to the invite name. Root cause: the icon/banner are CORD-02 §6 **encrypted media** — the metadata entity carries an `ImagePointer` object `{url,key,nonce,hash}` (AES-256-GCM ciphertext at `url`, decrypted with `key`/`nonce`, `hash` = SHA-256 of the plaintext) — but `MetadataEntity.icon` was typed `String?`. An object where a String is expected fails the whole entity's decode, so metadata came back null: no icon, and the name dropped to the entry fallback. Matches the Concord v2 reference client (Armada `concord-v2/lib/{types,image}.ts`). - Promote `ImagePointer` to a shared CORD-02 type (was invite-only) and give it `decryptOrNull` (AES-256-GCM via the existing `AESGCM`, verifying the plaintext SHA-256 — a swapped blob fails closed). - `MetadataEntity.icon`/`banner` are now `ImagePointer?`, so the entity (and the community name) decodes. `ConcordChannel` carries the pointers. - `rememberConcordImageModel` resolves a pointer for the avatar: a plain-URL pointer (Amethyst's own form) passes through; an encrypted one is fetched, decrypted, verified, cached to disk, and rendered — else the robohash. Wired into the Concord hub avatars and the Messages-tab community chip. - Amethyst's create/edit still take a URL and wrap it as a url-only pointer; authoring encrypted images (encrypt + upload) is a follow-up. Adds ImagePointerTest: Armada-shape object decode, decrypt round-trip, and fail-closed on a tampered hash. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 5 +- .../concord/ConcordCommunityImage.kt | 89 ++++++++++++++ .../concord/ConcordCreateScreen.kt | 7 +- .../concord/ConcordEditScreen.kt | 9 +- .../concord/ConcordHomeScreen.kt | 13 ++- .../chats/rooms/ChatroomHeaderCompose.kt | 5 +- .../commons/actions/ConcordActions.kt | 3 +- .../commons/model/concord/ConcordChannel.kt | 12 +- .../ConcordCommunityFactory.kt | 2 +- .../concord/cord02Community/ImagePointer.kt | 61 ++++++++++ .../concord/cord04Roles/ControlEntities.kt | 13 ++- .../concord/cord05Invites/CommunityInvite.kt | 10 +- .../cord02Community/ImagePointerTest.kt | 110 ++++++++++++++++++ 13 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointer.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointerTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e56d1f6f03..2ad44d3178 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -145,6 +145,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity @@ -1935,7 +1936,7 @@ class Account( name: String, description: String? = null, relays: List = emptyList(), - icon: String? = null, + icon: ImagePointer? = null, ): String? { if (!isWriteable()) return null val relayUrls = relays.ifEmpty { outboxRelays.flow.value.map { it.url } } @@ -2443,7 +2444,7 @@ class Account( communityId: String, name: String, description: String?, - icon: String?, + icon: ImagePointer?, relays: List, ): Boolean { val session = concordSessions.sessionFor(communityId) ?: return false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt new file mode 100644 index 0000000000..c6f38c3dbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +// Decrypt-once memo per plaintext hash → the on-disk file:// model. Object URLs never change for a +// given hash (content-addressed), so this is safe to keep for the process lifetime and bounded by the +// number of distinct community images a session touches. +private val resolvedByHash = ConcurrentHashMap() + +/** + * Resolve a CORD-02 §6 community [pointer] to a model string for [RobohashFallbackAsyncImage]: + * + * - **null / blank** → null (caller falls back to the robohash). + * - **plain URL** (a url-only pointer, e.g. Amethyst's own metadata form) → the URL, loaded directly. + * - **encrypted** (`key`/`nonce`/`hash` present) → fetch the ciphertext, AES-256-GCM-decrypt + verify + * the plaintext SHA-256 (all in [ImagePointer.decryptOrNull]), cache the plaintext to disk, and + * return its `file://` path. Returns null while loading or on any fetch/decrypt/integrity failure, + * so a swapped or unreachable blob simply shows the robohash instead of garbage. + */ +@Composable +fun rememberConcordImageModel( + pointer: ImagePointer?, + accountViewModel: AccountViewModel, +): String? { + if (pointer == null) return null + + // A url-only pointer isn't encrypted media — hand the URL straight to Coil. + if (!pointer.isResolvable()) return pointer.url.ifBlank { null } + + val context = LocalContext.current + val model by produceState(resolvedByHash[pointer.hash], pointer, accountViewModel) { + if (value != null) return@produceState + value = + withContext(Dispatchers.IO) { + runCatching { + resolvedByHash[pointer.hash]?.let { return@runCatching it } + + val cacheFile = File(context.cacheDir, "concord-img-${pointer.hash}") + if (!cacheFile.exists()) { + val client = accountViewModel.httpClientBuilder.okHttpClientForImage(pointer.url) + val ciphertext = + client.newCall(Request.Builder().url(pointer.url).build()).execute().use { resp -> + if (!resp.isSuccessful) return@runCatching null + resp.body?.bytes() + } ?: return@runCatching null + + val plaintext = pointer.decryptOrNull(ciphertext) ?: return@runCatching null + cacheFile.writeBytes(plaintext) + } + val uri = "file://${cacheFile.absolutePath}" + resolvedByHash[pointer.hash] = uri + uri + }.onFailure { Log.w("ConcordImage", "Failed to resolve community image ${pointer.url}", it) } + .getOrNull() + } + } + return model +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index d8c680f857..62d5c62f4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.launch @@ -133,7 +134,11 @@ fun ConcordCreateScreen( name = name.value.trim(), description = about.value.trim().ifBlank { null }, relays = relays.map { it.url }, - icon = iconUrl.value.trim().ifBlank { null }, + icon = + iconUrl.value + .trim() + .ifBlank { null } + ?.let { ImagePointer(url = it) }, ) working = false if (communityId != null) nav.newStack(Route.ConcordServer(communityId)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 888b858648..9a89e1d552 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -93,7 +94,7 @@ fun ConcordEditScreen( if (!prefilled && md != null) { name.value = md.name about.value = md.description.orEmpty() - iconUrl.value = md.icon.orEmpty() + iconUrl.value = md.icon?.url.orEmpty() prefilled = true } } @@ -143,7 +144,11 @@ fun ConcordEditScreen( communityId = communityId, name = name.value.trim(), description = about.value.trim().ifBlank { null }, - icon = iconUrl.value.trim().ifBlank { null }, + icon = + iconUrl.value + .trim() + .ifBlank { null } + ?.let { ImagePointer(url = it) }, relays = state?.metadata?.relays ?: session.entry.relays, ) working = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 058e0b9148..817cb48416 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** @@ -164,7 +165,7 @@ fun ConcordHomeScreen( CommunityHeader( communityId = entry.id, name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) }, - iconUrl = state?.metadata?.icon, + iconPointer = state?.metadata?.icon, channelCount = state?.channels?.size ?: 0, expanded = isOpen, accountViewModel = accountViewModel, @@ -213,7 +214,7 @@ private fun CommunityRail( contentPadding = PaddingValues(horizontal = 16.dp), ) { items(communities, key = { it.id }) { entry -> - val iconUrl = + val iconPointer = accountViewModel.account.concordSessions .sessionFor(entry.id) ?.state @@ -221,11 +222,12 @@ private fun CommunityRail( ?.metadata ?.icon .takeIf { revision >= 0 } + val iconModel = rememberConcordImageModel(iconPointer, accountViewModel) val isOpen = entry.id in expanded val ring = if (isOpen) MaterialTheme.colorScheme.primary else Color.Transparent RobohashFallbackAsyncImage( robot = entry.id, - model = iconUrl, + model = iconModel, contentDescription = entry.name, modifier = Modifier @@ -245,7 +247,7 @@ private fun CommunityRail( private fun CommunityHeader( communityId: String, name: String, - iconUrl: String?, + iconPointer: ImagePointer?, channelCount: Int, expanded: Boolean, accountViewModel: AccountViewModel, @@ -253,6 +255,7 @@ private fun CommunityHeader( onOpen: () -> Unit, ) { val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val iconModel = rememberConcordImageModel(iconPointer, accountViewModel) Row( modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, @@ -260,7 +263,7 @@ private fun CommunityHeader( ) { RobohashFallbackAsyncImage( robot = communityId, - model = iconUrl, + model = iconModel, contentDescription = name, modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onOpen), loadProfilePicture = accountViewModel.settings.showProfilePictures(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 1efa084f19..7d893641e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote @@ -446,7 +447,7 @@ private fun ConcordRoomCompose( ChannelName( channelIdHex = channel.channelId.channelId, - channelPicture = channel.communityIcon, + channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel), channelTitle = { modifier -> Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Text( @@ -549,7 +550,7 @@ private fun ConcordServerRoomCompose( ChannelName( channelIdHex = row.communityId, - channelPicture = metadata?.icon, + channelPicture = rememberConcordImageModel(metadata?.icon, accountViewModel), channelTitle = { modifier -> ChannelTitleWithLabelInfo(name, R.string.concord_server_label, modifier) }, channelLastTime = row.newestMessage?.createdAt(), channelLastContent = lastContent, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 45e9ac2c90..a9161c62cb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.Guestbook import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys @@ -125,7 +126,7 @@ object ConcordActions { createdAt: Long, description: String? = null, relays: List = emptyList(), - icon: String? = null, + icon: ImagePointer? = null, ): NewConcordCommunity = ConcordCommunityFactory.create(ownerSigner, name, createdAt, description, relays, icon) /** Opens the control-plane [wraps] into their [ControlEdition]s (drops any that don't open/parse). */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt index 6a04ac29fa..67f090e3fa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors @@ -61,8 +62,12 @@ class ConcordChannel( var communityName: String? = null private set - /** The parent community's icon URL, from its folded metadata (null if unset). */ - var communityIcon: String? = null + /** The parent community's encrypted-media icon pointer, from its folded metadata (null if unset). */ + var communityIcon: ImagePointer? = null + private set + + /** The parent community's encrypted-media banner pointer, from its folded metadata (null if unset). */ + var communityBanner: ImagePointer? = null private set /** The community's bootstrap relays — a channel plane may be mirrored on all of them. */ @@ -103,6 +108,7 @@ class ConcordChannel( val newPrivate = def?.private ?: isPrivate val newCommunityName = state.metadata?.name val newCommunityIcon = state.metadata?.icon + val newCommunityBanner = state.metadata?.banner val newMembership = ConcordMembership.of(state.authority, myPubKey) val changed = @@ -111,6 +117,7 @@ class ConcordChannel( isPrivate != newPrivate || communityName != newCommunityName || communityIcon != newCommunityIcon || + communityBanner != newCommunityBanner || membership != newMembership channelName = newChannelName @@ -118,6 +125,7 @@ class ConcordChannel( isPrivate = newPrivate communityName = newCommunityName communityIcon = newCommunityIcon + communityBanner = newCommunityBanner communityRelays = relays membership = newMembership return changed diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt index 314bc953cd..f7d320c5ff 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt @@ -76,7 +76,7 @@ object ConcordCommunityFactory { createdAt: Long, description: String? = null, relays: List = emptyList(), - icon: String? = null, + icon: ImagePointer? = null, ): NewConcordCommunity { val ownerXOnly = ownerSigner.pubKey.hexToByteArray() val ownerSalt = ConcordKeyDerivation.newOwnerSalt() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointer.kt new file mode 100644 index 0000000000..8c194f660e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointer.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.Serializable + +/** + * A CORD-02 §6 **encrypted-media** pointer (community/channel icon or banner). The media host stores + * only ciphertext; the per-image AES-256-GCM [key] + [nonce] ride inside the member-sealed Control + * Plane metadata, and [hash] is the SHA-256 of the *plaintext* so a swapped blob fails closed. + * + * Wire shape is pinned to the Concord v2 reference client (`concord-v2/lib/types.ts`): an object, + * not a URL string — a member fetches [url], AES-256-GCM-decrypts with [key]/[nonce], then verifies + * the plaintext SHA-256 equals [hash] before displaying. + */ +@Serializable +data class ImagePointer( + val url: String = "", + /** Hex AES-256-GCM key (32 bytes). */ + val key: String = "", + /** Hex AES-GCM nonce / IV (16 bytes). */ + val nonce: String = "", + /** Hex SHA-256 of the plaintext, for integrity. */ + val hash: String = "", +) { + /** True once every field needed to fetch + decrypt is present. */ + fun isResolvable(): Boolean = url.isNotBlank() && key.isNotBlank() && nonce.isNotBlank() && hash.isNotBlank() + + /** + * Decrypt the fetched [ciphertext] blob (AES-256-GCM under [key]/[nonce], CORD-02 §6) and verify + * the plaintext SHA-256 against [hash]. Returns the plaintext image bytes, or null if decryption or + * the integrity check fails — a swapped or corrupt blob fails closed rather than rendering garbage. + */ + fun decryptOrNull(ciphertext: ByteArray): ByteArray? { + val plaintext = AESGCM(key.hexToByteArray(), nonce.hexToByteArray()).decryptOrNull(ciphertext) ?: return null + if (sha256(plaintext).toHexKey() != hash.lowercase()) return null + return plaintext + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index 9673a26516..e7df8bd3a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.concord.cord04Roles +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.ListSerializer @@ -99,13 +100,19 @@ class ChannelEntity( ) /** - * A community's Metadata content (CORD-02): display [name], optional [icon] and - * [description], and the community's bootstrap [relays]. Client-extensible. + * A community's Metadata content (CORD-02): display [name], optional [description], the community's + * bootstrap [relays], and the encrypted-media [icon]/[banner] pointers. Client-extensible. + * + * [icon]/[banner] are CORD-02 §6 [ImagePointer]s (an object `{url,key,nonce,hash}`), NOT plain URLs — + * the wire shape is pinned to the Concord v2 reference client. Deserializing them into anything else + * (e.g. a `String`) fails the whole entity's decode, which is why a wrong type silently drops the + * community name too. */ @Serializable class MetadataEntity( val name: String = "", - val icon: String? = null, + val icon: ImagePointer? = null, + val banner: ImagePointer? = null, val description: String? = null, val relays: List = emptyList(), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt index 95d94ffc61..2cafcfe5f3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -20,18 +20,10 @@ */ package com.vitorpamplona.quartz.concord.cord05Invites +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -/** An encrypted-media image reference (CORD-02): where the bytes are and how to decrypt them. */ -@Serializable -class ImagePointer( - val url: String = "", - val key: String = "", - val nonce: String = "", - val hash: String = "", -) - /** A channel grant carried in an invite: its id, delivered [key], [epoch], and [name]. */ @Serializable class InviteChannel( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointerTest.kt new file mode 100644 index 0000000000..c13c53164c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointerTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ImagePointerTest { + /** + * The community icon/banner are CORD-02 §6 [ImagePointer] *objects* on the wire (Concord v2 + * reference client), not URL strings. Typing them as `String` — the old bug — makes the whole + * MetadataEntity fail to decode, silently dropping the community name too. This pins the object + * shape decoding correctly, name included. + */ + @Test + fun decodesArmadaShapeMetadataWithEncryptedIconObject() { + val json = + """ + { + "name": "NosFabrica", + "description": "a community", + "icon": { "url": "https://media.example/icon.enc", "key": "${"1a".repeat(32)}", "nonce": "${"2b".repeat(16)}", "hash": "${"3c".repeat(32)}" }, + "banner": { "url": "https://media.example/banner.enc", "key": "${"4d".repeat(32)}", "nonce": "${"5e".repeat(16)}", "hash": "${"6f".repeat(32)}" }, + "relays": ["wss://relay.example/"] + } + """.trimIndent() + + val md = ConcordJson.decodeOrNull(json) + assertNotNull(md, "an object-shaped icon must decode, not fail the whole entity") + assertEquals("NosFabrica", md.name) + assertEquals("https://media.example/icon.enc", md.icon?.url) + assertEquals("2b".repeat(16), md.icon?.nonce) + assertEquals("https://media.example/banner.enc", md.banner?.url) + assertTrue(md.icon!!.isResolvable()) + } + + /** A metadata with no images still decodes (both pointers null). */ + @Test + fun decodesMetadataWithoutImages() { + val md = ConcordJson.decodeOrNull("""{"name":"NoPics"}""") + assertNotNull(md) + assertEquals("NoPics", md.name) + assertNull(md.icon) + assertNull(md.banner) + } + + /** decryptOrNull round-trips AES-256-GCM with the pointer's key/nonce and verifies the plaintext hash. */ + @Test + fun decryptRoundTripsAndVerifiesHash() { + val plaintext = "the real PNG bytes".encodeToByteArray() + val key = ByteArray(32) { it.toByte() } + val nonce = ByteArray(16) { (it + 7).toByte() } + val ciphertext = AESGCM(key, nonce).encrypt(plaintext) + + val pointer = + ImagePointer( + url = "https://media.example/blob", + key = key.toHexKey(), + nonce = nonce.toHexKey(), + hash = sha256(plaintext).toHexKey(), + ) + + assertEquals(plaintext.toHexKey(), pointer.decryptOrNull(ciphertext)?.toHexKey()) + } + + /** A swapped blob (wrong plaintext hash) fails closed — decryptOrNull returns null, never garbage. */ + @Test + fun tamperedHashFailsClosed() { + val plaintext = "original".encodeToByteArray() + val key = ByteArray(32) { it.toByte() } + val nonce = ByteArray(16) { it.toByte() } + val ciphertext = AESGCM(key, nonce).encrypt(plaintext) + + val wrongHashPointer = + ImagePointer( + url = "https://media.example/blob", + key = key.toHexKey(), + nonce = nonce.toHexKey(), + hash = "00".repeat(32), // not the plaintext's hash + ) + + assertNull(wrongHashPointer.decryptOrNull(ciphertext)) + } +}