diff --git a/rns-api/src/main/java/network/columba/app/rns/api/util/LxmfFields.kt b/rns-api/src/main/java/network/columba/app/rns/api/util/LxmfFields.kt index 27ea2cb7a..6295f9199 100644 --- a/rns-api/src/main/java/network/columba/app/rns/api/util/LxmfFields.kt +++ b/rns-api/src/main/java/network/columba/app/rns/api/util/LxmfFields.kt @@ -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 = ` 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. diff --git a/rns-api/src/main/java/network/columba/app/rns/api/util/ReactionWireCodec.kt b/rns-api/src/main/java/network/columba/app/rns/api/util/ReactionWireCodec.kt index 41d2e09c4..e5eea14d4 100644 --- a/rns-api/src/main/java/network/columba/app/rns/api/util/ReactionWireCodec.kt +++ b/rns-api/src/main/java/network/columba/app/rns/api/util/ReactionWireCodec.kt @@ -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) = ` (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": , "emoji": , "sender": } @@ -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. * @@ -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' } } + } + /** 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 diff --git a/rns-api/src/test/java/network/columba/app/rns/api/util/ReactionWireCodecTest.kt b/rns-api/src/test/java/network/columba/app/rns/api/util/ReactionWireCodecTest.kt index 6bb8a719a..cdd473530 100644 --- a/rns-api/src/test/java/network/columba/app/rns/api/util/ReactionWireCodecTest.kt +++ b/rns-api/src/test/java/network/columba/app/rns/api/util/ReactionWireCodecTest.kt @@ -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 = + 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 = + 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")) + } + + @Test + fun `stamp type present but data blank falls back to source hash`() { + val fields: Map = + 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 = + 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