Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,30 @@ object LxmfFields {
*/
const val FIELD_REPLY_QUOTE = 0x31

/**
* Upstream LXMF `FIELD_CUSTOM_TYPE` (0xFB) — app-defined type tag that
* names the opaque payload in [FIELD_CUSTOM_DATA]. Read-only here:
* Columba uses it to recover reactor attribution for reactions relayed
* through a re-originating group relay (e.g. reticulum-forwarding-service).
* A relay re-signs each reaction as itself, so the carrying
* `source_hash` is the relay, not the reactor; the relay stamps
* `FIELD_CUSTOM_TYPE = "originator-identity"` +
* `FIELD_CUSTOM_DATA = <reactor source_hash>` so cooperating clients
* attribute the reaction to the real reactor. See `ReactionWireCodec`.
*/
const val FIELD_CUSTOM_TYPE = 0xFB

/**
* Upstream LXMF `FIELD_CUSTOM_DATA` (0xFC) — app-defined opaque payload
* whose meaning is given by [FIELD_CUSTOM_TYPE]. For the
* `"originator-identity"` type this is the reactor's raw 16-byte
* `source_hash` — its `lxmf.delivery` destination hash, the same value
* a direct reaction carries and what contacts are keyed by (NOT the raw
* identity hash, which would orphan the lookup). It arrives hex-encoded
* in the serialized fields JSON. See `ReactionWireCodec`.
*/
const val FIELD_CUSTOM_DATA = 0xFC

/**
* Upstream LXMF `FIELD_CUSTOM_META` (0xFD) — documented extension point
* for app-specific metadata that other LXMF clients should ignore.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ import org.json.JSONObject
* the LXMF message's source hash, so [parseInboundReaction] sets `sender`
* to the inbound source hash on the canonical path.
*
* **Relay attribution override.** A re-originating group relay (e.g.
* reticulum-forwarding-service) re-signs each reaction as itself, so the
* carrying source hash is the relay, not the reactor. Such a relay stamps
* the reactor's source hash into top-level custom fields —
* `FIELD_CUSTOM_TYPE (0xFB) = "originator-identity"`,
* `FIELD_CUSTOM_DATA (0xFC) = <reactor source_hash>` (its destination hash,
* the same value a direct reaction carries and what contacts key by). When
* that stamp is present, the canonical path uses it as `sender` instead of
* the relay's source hash. Purely additive and backward compatible: direct
* reactions carry no stamp and fall back to source hash.
* (Convention: `reticulum-forwarding-service/docs/reaction-attribution.md`.)
*
* **Legacy wire shape** (parse-only fallback, pre-standard Columba/MeshChatX):
* ```
* fields[0x10] = { "reaction_to": <hex>, "emoji": <unicode>, "sender": <hex> }
Expand Down Expand Up @@ -44,6 +56,15 @@ object ReactionWireCodec {
private val REACTION_CONTENT_KEY = LxmfFields.REACTION_CONTENT.toString()
private val FIELD_REACTION_LEGACY_KEY = LxmfFields.FIELD_REACTION_LEGACY.toString()

// Reactor-attribution stamp set by a re-originating group relay (see
// FIELD_CUSTOM_TYPE / FIELD_CUSTOM_DATA on LxmfFields). The relay
// re-signs each reaction as itself, so the carrying source_hash is the
// relay; when these top-level fields are present, the reactor identity
// hash in FIELD_CUSTOM_DATA is authoritative over the source hash.
private val FIELD_CUSTOM_TYPE_KEY = LxmfFields.FIELD_CUSTOM_TYPE.toString()
private val FIELD_CUSTOM_DATA_KEY = LxmfFields.FIELD_CUSTOM_DATA.toString()
private const val ORIGINATOR_IDENTITY_TYPE = "originator-identity"

/**
* Build the canonical `fields[0x40]` map for an outbound reaction.
*
Expand Down Expand Up @@ -106,16 +127,38 @@ object ReactionWireCodec {
val emoji =
dict.optString(REACTION_CONTENT_KEY).takeIf { it.isNotBlank() }?.let { decodeUtf8Hex(it) }
if (reactionTo == null || emoji == null) return null
// Standard: the reactor is the message source, not a wire field.
// Standard: the reactor is the message source. But a re-originating
// group relay re-signs each reaction as itself, so source_hash would
// attribute every relayed reaction to the relay. When the relay
// stamped its reactor-attribution custom fields, the reactor
// source_hash in FIELD_CUSTOM_DATA is authoritative. Falls back to
// source_hash for direct (non-relayed) reactions, which carry no stamp.
val sender = originatorIdentityHex(fields) ?: sourceHashHex
return normalized(
reactionTo = reactionTo.lowercase(),
emoji = emoji,
sender = sourceHashHex,
sender = sender,
sourceHashHex = sourceHashHex,
timestamp = timestamp,
)
}

/**
* Read a re-originating relay's reactor-attribution stamp, returning the
* reactor's source-hash hex when present and well-formed, else null.
* Requires `FIELD_CUSTOM_TYPE == "originator-identity"` and a non-blank
* hex `FIELD_CUSTOM_DATA` (the serializer hex-encodes the raw 16-byte
* source hash). Both fields sit at the top level of the field map,
* alongside `fields[0x40]`.
*/
private fun originatorIdentityHex(fields: JSONObject): String? {
if (fields.optString(FIELD_CUSTOM_TYPE_KEY) != ORIGINATOR_IDENTITY_TYPE) return null
return fields
.optString(FIELD_CUSTOM_DATA_KEY)
.takeIf { it.isNotBlank() }
?.lowercase()
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/** Legacy `fields[0x10] = {reaction_to, emoji, sender}` (string-keyed). */
private fun parseLegacy(fields: JSONObject, sourceHashHex: String, timestamp: Long): String? {
val dict = fields.optJSONObject(FIELD_REACTION_LEGACY_KEY) ?: return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,59 @@ class ReactionWireCodecTest {
}
}

// ================== relay attribution stamp ==================

// The reactor's 16-byte source_hash (its lxmf.delivery destination hash)
// — distinct from the relay's source hash on the carrying message.
private val reactorIdentity = "fbaa52ed547644cfffe48ecf1ae1c355"

@Test
fun `relay originator-identity stamp overrides source hash as sender`() {
// What a re-originating relay puts on the wire: canonical 0x40 plus the
// top-level FIELD_CUSTOM_TYPE/DATA stamp. Serialized through the real
// AppDataParser so the 0xFB string + 0xFC bytes hex-encode exactly as
// on the JNI/UI boundary.
val fields: Map<Int, Any> =
ReactionWireCodec.encodeReactionFields(targetHash, "👍")!! +
mapOf(
LxmfFields.FIELD_CUSTOM_TYPE to "originator-identity",
LxmfFields.FIELD_CUSTOM_DATA to reactorIdentity.hexToBytes(),
)
val fieldsJson = AppDataParser.serializeFieldsToJson(fields)
val parsed =
ReactionWireCodec.parseInboundReaction(fieldsJson, sourceHash, 7L)?.let { JSONObject(it) }
requireNotNull(parsed)
assertEquals(targetHash, parsed.getString("reaction_to"))
assertEquals("👍", parsed.getString("emoji"))
// Stamp wins: attribute to the reactor identity, NOT the relay source.
assertEquals(reactorIdentity, parsed.getString("sender"))
// source_hash still records the carrying (relay) source.
assertEquals(sourceHash, parsed.getString("source_hash"))
}

@Test
fun `stamp with a non-matching type tag is ignored and falls back to source hash`() {
val fields: Map<Int, Any> =
ReactionWireCodec.encodeReactionFields(targetHash, "👍")!! +
mapOf(
LxmfFields.FIELD_CUSTOM_TYPE to "something-else",
LxmfFields.FIELD_CUSTOM_DATA to reactorIdentity.hexToBytes(),
)
val fieldsJson = AppDataParser.serializeFieldsToJson(fields)
val parsed =
ReactionWireCodec.parseInboundReaction(fieldsJson, sourceHash, 7L)?.let { JSONObject(it) }
requireNotNull(parsed)
assertEquals(sourceHash, parsed.getString("sender"))
}

@Test
fun `direct reaction with no stamp still derives sender from source`() {
// Backward compatibility: the unstamped canonical path is unchanged.
val parsed = roundTrip(targetHash, "👍", sourceHash, timestamp = 7L)
requireNotNull(parsed)
assertEquals(sourceHash, parsed.getString("sender"))
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// ===================== legacy 0x10 fallback =====================

@Test
Expand Down