Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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,62 @@ 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.
//
// ⚠️ TRUST: the stamp is unauthenticated (see originatorIdentityHex).
// It is only safe to honor when this reaction provably came via a
// trusted relay; a direct peer could otherwise forge attribution to a
// third party. This codec has no DB/conversation context, so the
// final trust decision belongs to the caller
// (MessagingViewModel.handleIncomingReaction), which should ignore the
// override unless the carrying source_hash matches the relay/group
// that delivered the reacted-to message. `source_hash` (the real
// carrier) is preserved in the normalized output for exactly that.
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
* `FIELD_CUSTOM_DATA` that is exactly a 32-hex-char destination hash (the
* serializer hex-encodes the raw 16-byte source hash). A blank, wrong-
* length, or non-hex value is rejected so it can never be stored as an
* unresolvable key in `reactionsJson`. Both fields sit at the top level
* of the field map, alongside `fields[0x40]`.
*
* ⚠️ SECURITY — this stamp is an **unauthenticated** assertion. It is
* cryptographically sound only when the carrying message demonstrably
* arrived via a **trusted relay** (the re-originating relay verified the
* reactor's signature before stamping). A direct, non-relay peer can set
* these same fields to attribute a reaction to an arbitrary third party.
* Callers MUST therefore only trust the override when the reaction
* arrived from a trusted relay context — e.g. gate it on the carrying
* message's `source_hash` matching the relay/group source that delivered
* the target message being reacted to (see the call site in
* `parseCanonical` and `MessagingViewModel.handleIncomingReaction`).
*/
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()
?.takeIf { it.length == 32 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } }
}
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,107 @@ 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.

@Test
fun `stamp type present but data blank falls back to source hash`() {
val fields: Map<Int, Any> =
ReactionWireCodec.encodeReactionFields(targetHash, "👍")!! +
mapOf(LxmfFields.FIELD_CUSTOM_TYPE to "originator-identity")
// FIELD_CUSTOM_DATA absent entirely.
val parsed =
ReactionWireCodec.parseInboundReaction(
AppDataParser.serializeFieldsToJson(fields),
sourceHash,
7L,
)?.let { JSONObject(it) }
requireNotNull(parsed)
assertEquals(sourceHash, parsed.getString("sender"))
}

@Test
fun `malformed stamp data is rejected and falls back to source hash`() {
// An attacker controls the wire bytes; FIELD_CUSTOM_DATA can arrive as
// a str of any shape. Wrong length / non-hex must never become a
// reactionsJson sender key — fall back to source_hash. (A String value
// serializes through AppDataParser as-is, unlike a ByteArray which is
// always hex-encoded, so this exercises the non-hex guard too.)
val badValues =
listOf(
"deadbeef", // too short (8)
"${reactorIdentity}ff", // too long (34)
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", // 32 chars, non-hex
"abcdef00112233445566778899aabbgg", // 32 chars, trailing non-hex
)
for (bad in badValues) {
val fields: Map<Int, Any> =
ReactionWireCodec.encodeReactionFields(targetHash, "👍")!! +
mapOf(
LxmfFields.FIELD_CUSTOM_TYPE to "originator-identity",
LxmfFields.FIELD_CUSTOM_DATA to bad, // String → serialized verbatim
)
val parsed =
ReactionWireCodec.parseInboundReaction(
AppDataParser.serializeFieldsToJson(fields),
sourceHash,
7L,
)?.let { JSONObject(it) }
requireNotNull(parsed) { "round-trip failed for bad data $bad" }
assertEquals("malformed stamp '$bad' should fall back", sourceHash, parsed.getString("sender"))
}
}

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

@Test
Expand Down