mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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:
co-authored by
Claude Opus 4.8
parent
975ccb9cf3
commit
28b93132a3
@@ -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
|
||||
|
||||
+89
@@ -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
|
||||
}
|
||||
+6
-1
@@ -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))
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
+8
-5
@@ -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(),
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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). */
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+61
@@ -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
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -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(),
|
||||
)
|
||||
|
||||
+1
-9
@@ -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(
|
||||
|
||||
+110
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user