feat(concord): read + render CORD-02 §6 encrypted community icon/banner

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-13 17:16:12 -04:00
co-authored by Claude Opus 4.8
parent 975ccb9cf3
commit 28b93132a3
13 changed files with 311 additions and 28 deletions
@@ -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<String> = 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<String>,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
@@ -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<String, String>()
/**
* 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<String?>(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
}
@@ -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))
@@ -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
@@ -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(),
@@ -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,
@@ -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<String> = 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). */
@@ -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
@@ -76,7 +76,7 @@ object ConcordCommunityFactory {
createdAt: Long,
description: String? = null,
relays: List<String> = emptyList(),
icon: String? = null,
icon: ImagePointer? = null,
): NewConcordCommunity {
val ownerXOnly = ownerSigner.pubKey.hexToByteArray()
val ownerSalt = ConcordKeyDerivation.newOwnerSalt()
@@ -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
}
}
@@ -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<String> = emptyList(),
)
@@ -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(
@@ -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<MetadataEntity>(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<MetadataEntity>("""{"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))
}
}