feat(reactions): attribute relayed reactions to the reactor via originator-identity stamp#1006
Conversation
…nator-identity stamp Reactions relayed through a re-originating group relay (e.g. reticulum-forwarding-service) are currently all attributed to the relay: the relay re-signs each reaction as itself, and a reaction has no body to carry authorship, so the carrying source_hash is the relay's. Such a relay stamps the reactor's source_hash (its lxmf.delivery destination hash — the same value a direct reaction carries and what contacts are keyed by) into upstream's app-convention custom fields: FIELD_CUSTOM_TYPE (0xFB) = "originator-identity", FIELD_CUSTOM_DATA (0xFC) = reactor source_hash. This teaches ReactionWireCodec to use that stamp as `sender` on the canonical 0x40 path when present, falling back to source_hash for direct reactions. Purely additive and backward compatible: the 0x40 wire format is untouched, direct reactions carry no stamp, and the change lives in the shared rns-api codec so both the kotlin-native and python backends pick it up. Convention documented at thatSFguy/reticulum-forwarding-service:docs/reaction-attribution.md.
Greptile SummaryThis PR adds relay attribution support so that reactions forwarded by a re-originating group relay (e.g.
Confidence Score: 4/5The codec change is additive and backward-compatible; the validation and tests are solid, but the caller (MessagingViewModel.handleIncomingReaction) does not yet implement the relay-context trust gate that the KDoc explicitly requires. The stamp parsing, hex-format validation, and five new round-trip tests are all correct. The codec itself is clean. The open gap is that handleIncomingReaction reads only the app/src/main/java/network/columba/app/viewmodel/MessagingViewModel.kt — handleIncomingReaction needs to verify source_hash against a trusted relay before accepting the stamp override. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Reactor
participant Relay as Re-originating Relay
participant Columba as Columba Client
Note over Reactor,Relay: Direct reaction (unstamped)
Reactor->>Columba: "LXMessage{fields[0x40]={target, emoji}}, source_hash = Reactor"
Note over Reactor,Relay: Relayed reaction (stamped)
Reactor->>Relay: "LXMessage{fields[0x40]={target, emoji}}, source_hash = Reactor"
Relay->>Columba: "LXMessage{fields[0x40]={target, emoji}, fields[0xFB]="originator-identity", fields[0xFC]=Reactor.source_hash}, source_hash = Relay"
Note over Columba: ReactionWireCodec.parseCanonical()
Columba->>Columba: originatorIdentityHex(fields)? Reactor.source_hash (stamp present, validated) else sourceHashHex (unstamped)
Columba->>Columba: "normalized{sender=Reactor, source_hash=Relay}"
Columba->>Columba: handleIncomingReaction() mergeReactionIntoJson(sender)
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Reactor
participant Relay as Re-originating Relay
participant Columba as Columba Client
Note over Reactor,Relay: Direct reaction (unstamped)
Reactor->>Columba: "LXMessage{fields[0x40]={target, emoji}}, source_hash = Reactor"
Note over Reactor,Relay: Relayed reaction (stamped)
Reactor->>Relay: "LXMessage{fields[0x40]={target, emoji}}, source_hash = Reactor"
Relay->>Columba: "LXMessage{fields[0x40]={target, emoji}, fields[0xFB]="originator-identity", fields[0xFC]=Reactor.source_hash}, source_hash = Relay"
Note over Columba: ReactionWireCodec.parseCanonical()
Columba->>Columba: originatorIdentityHex(fields)? Reactor.source_hash (stamp present, validated) else sourceHashHex (unstamped)
Columba->>Columba: "normalized{sender=Reactor, source_hash=Relay}"
Columba->>Columba: handleIncomingReaction() mergeReactionIntoJson(sender)
Reviews (2): Last reviewed commit: "review: validate stamp format, document ..." | Re-trigger Greptile |
…dge cases Addresses the Greptile review on the originator-identity stamp: - Validate FIELD_CUSTOM_DATA is exactly a 32-hex-char destination hash before using it as `sender`; a blank/wrong-length/non-hex value is rejected so it can never become an unresolvable key in reactionsJson. - Document the trust assumption prominently: the stamp is UNAUTHENTICATED and safe only when the reaction arrived via a trusted relay. Since the codec has no DB context, the final trust gate (honor the override only when source_hash matches the relay/group that delivered the reacted-to message) belongs in MessagingViewModel.handleIncomingReaction — flagged at both the helper and the call site. source_hash is preserved in the normalized output so the caller can make that decision. - Add tests: stamp-type-present-but-data-absent → fallback; malformed (wrong length / non-hex) data → fallback.
…for the stamp The originator-identity stamp is unauthenticated, so a receiver that honors it from any source lets a direct peer forge reactions attributed to a third party. Document the two receiver MUSTs: validate 0xFC is a well-formed 16-byte source_hash, and only honor the stamp when the reaction arrived via a trusted relay (carrying source_hash matches the relay/group that delivered the reacted-to message); else fall back to source_hash. Surfaced by the Greptile review on torlando-tech/columba#1006. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough review — the security finding (#1) is valid and important; I should have flagged the trust model in the original PR. Pushed #2 — #3 — edge-case tests ✅ Added: stamp type present but #1 — unauthenticated stamp / spoofing — agreed, this is the real one. The stamp is an unsigned assertion: its trust comes entirely from the relay (a re-originating relay verifies the reactor's LXMF signature before stamping, the same way you already trust the relay's authorship on relayed text). So the correct fix is a receiver-side trust gate: honor the override only when the reaction provably arrived via a trusted relay — concretely, when the carrying That gate needs DB/conversation context (the target message's source), which lives in I also made the trust gate a normative receiver requirement in the upstream convention doc so every client implements it consistently: https://github.com/thatSFguy/reticulum-forwarding-service/blob/main/docs/reaction-attribution.md#security (Still couldn't run |
Problem
When reactions are relayed through a re-originating group relay (e.g.
reticulum-forwarding-service), Columba currently attributes all of them to the relay. The relay re-signs each reaction as itself, and a reaction has no body to carry authorship — so the carryingsource_hashis the relay's, andReactionWireCodec.parseCanonicalsetssender = sourceHashHexaccordingly.Columba already speaks the canonical
fields[0x40] = {0x00: target, 0x01: emoji}shape (emit + parse), so reactions through the relay already render — they're just all collapsed onto the relay's identity.Fix
A re-originating relay stamps the reactor's
source_hash(itslxmf.deliverydestination hash — the same value a direct reaction carries, and what contacts are keyed by) into upstream's app-convention custom fields:This PR teaches
ReactionWireCodec.parseCanonicalto use that stamp assenderwhen present, falling back tosource_hashfor direct (unstamped) reactions.0x40wire format is untouched; direct reactions carry no stamp.rns-api'sReactionWireCodec, so both the kotlin-native and python backends pick it up.AppDataParser.serializeFieldsToJsonalready passes the top-level251/252fields through, andserializeFieldValuehex-encodes the0xFCbytes.source_hashis a stable per-reactor key that resolves against contacts (keyed by destination hash).Spec basis
The value is the reactor's
source_hash(destination hash), per the Reticulum/LXMF spec: §5.9.8 (reaction attribution rides onsource_hash) and §9.1 (an LXMFsource_hashis the destination hash; contacts are keyed by it — using the raw identity hash would orphan the lookup). The0xFB/0xFCfields are the reserved app-convention fields (§5.9.1). Full convention, with rationale and the cross-client decode notes, is documented atreticulum-forwarding-service/docs/reaction-attribution.md.Changes
LxmfFields.kt— addFIELD_CUSTOM_TYPE(0xFB) +FIELD_CUSTOM_DATA(0xFC).ReactionWireCodec.kt— read theoriginator-identitystamp inparseCanonical; KDoc updated.ReactionWireCodecTest.kt— stamp overrides source assender; non-matching tag ignored; unstamped direct reactions unchanged. Tests serialize through the realAppDataParserto exercise the exact wire encoding.Caveat
I don't have an Android/Gradle toolchain here, so I could not run
./gradlew :rns-api:test— please run it before merge. The change is small, logic-verified againstserializeFieldValue's behaviour, and style-matched to the existing tests.Context: this follows up #926.