Skip to content

feat(walletapi): mark and scrub unverified sender attribution for rings > 2#21

Open
DHEBP wants to merge 5 commits into
DEROFDN:community-devfrom
DHEBP:feat/sender-verified
Open

feat(walletapi): mark and scrub unverified sender attribution for rings > 2#21
DHEBP wants to merge 5 commits into
DEROFDN:community-devfrom
DHEBP:feat/sender-verified

Conversation

@DHEBP

@DHEBP DHEBP commented Jun 13, 2026

Copy link
Copy Markdown

The decrypted sender-index byte is sender-chosen and unauthenticated, so for any ring > 2 a sender can name the wrong member as the sender. This surfaces SenderVerified (true only for the structural ring-2 case) and RingSize on rpc.Entry so receivers can tell a structural attribution from a guess, and then acts on that flag: when !SenderVerified it blanks entry.Sender and zeroes the leading slot byte of entry.Data, so an unverified attribution can't be re-derived from any exported field via the public Publickeylist. The verified (ring-2) path and the self/send decode sites are unchanged; a source-level guard locks the scrub at both receiver paths. Pairs with #20 — together they close the receiver-side attribution finding.

DHEBP added 2 commits June 13, 2026 13:03
…anic

The receiver decode paths used sender_idx <= RingSize against a
0-indexed Publickeylist of length RingSize, so a crafted byte equal
to RingSize indexed one slot past the slice and panicked the
receiving wallet during history processing. Tighten to < in both the
CBOR and CBOR_V2 paths so valid indices are 0..RingSize-1.
Add SenderVerified and RingSize to rpc.Entry, populated in both
receiver decode paths. SenderVerified is true only at ring size 2,
where the receiver override pins attribution to the counterparty;
for larger rings the sender chose the unauthenticated attribution
byte, so entry.Sender must not be presented as trusted. RingSize is
carried so consumers can render attribution confidence per entry.
…ry fields

For ring sizes greater than 2 the receiver-decode attribution byte is
sender-chosen and unauthenticated, yet entry.Sender was set from it and
the raw byte was shipped in the exported entry.Data. Because the ring
Publickeylist is public, a consumer could re-derive the claimed sender
from entry.Data[0] even when entry.Sender was trusted, letting a sender
name any ring member as the apparent sender of a payment they never made.

Blank entry.Sender and zero the leading slot byte of the entry.Data copy
when !SenderVerified, so an unverified attribution cannot be re-derived
from any exported field. The verified (ring==2) path and the self/send
decode sites are unchanged.

Enforces the SenderVerified flag added in 1a0e0ba, which until now had no
consumer. A source-level guard locks the scrub at both receiver decode
paths so a regression fails loud. This is a decode-time scrub and does
not retroactively clean entries persisted before the change.
@DHEBP DHEBP changed the title feat(walletapi): mark sender attribution unverified for rings > 2 feat(walletapi): mark and scrub unverified sender attribution for rings > 2 Jun 20, 2026
@Dirtybird99

Copy link
Copy Markdown

PR #21 — Six-Perspective Review

Multi-perspective review (Product / Developer / QE / Security / DevOps / UI-UX). Every factual claim below was independently verification-checked and is tagged CONFIRMED, PLAUSIBLE, or REFUTED.

Bottom line

Request changes (small, well-scoped). The central question — when a sender attribution can't be authenticated, present a forgeable guess or refuse? — is answered correctly and at the right layer (the decode/export boundary, before any field leaves the wallet). The crypto reasoning holds: no exported field (Sender, Data, Payload, Payload_RPC, Proof, Destination) can re-derive the claimed sender after the scrub. But the answer is shipped incomplete, and one line the PR is actively editing has a latent out-of-bounds bug. None of the gaps are deferrable — they sit on lines this PR already touches.

🔴 Fix before merge

1. Bounds fix checks the wrong collection — residual OOB panic. Security · medium · PLAUSIBLEdaemon_communication.go:1074,1128
The <=< fix guards sender_idx < RingSize, then indexes Publickeylist[sender_idx]. But Publickeylist is built with a conditional append (:866-873): a key that fails DecodeCompressed is skipped, so len(Publickeylist) can be < RingSize. A crafted sender_idx ∈ [len(Publickeylist), RingSize) still passes the guard → index panic (caught by the V(1) recover at :542, degrading to a silent sync stall for that SCID). Since the PR is already editing this line, bound against the real slice: if sender_idx < uint(len(...Publickeylist)). (PLAUSIBLE, not CONFIRMED, only because the trigger needs a ring key that round-trips through NewAddress+EncodeCompressed yet fails DecodeCompressed — the codec guarantee isn't visible in-tree.)

2. Self/send transfers export SenderVerified=false with a populated Sender. PM + QE + Security + UI/UX · medium · CONFIRMED ×4daemon_communication.go:985-987, 1010-1012
The two self-send sites set entry.Sender to the wallet's own address (certain) but never set SenderVerified/RingSize, so they default to false/0. The field doc says "entry.Sender MUST NOT be trusted" when !SenderVerified — so a consumer following the contract distrusts the most certain attribution in the system (the user's own sends). Fix: set SenderVerified=true (and RingSize) at both self-send sites, or document the flag as receive-path-only.

3. The only test executes no decode path; scrub behavior is untested. QE · high · CONFIRMEDattribution_export_guard_test.go:21-55
It os.ReadFiles daemon_communication.go and runs three regexps over the source text — it never constructs an Entry or runs a decode. A logically-wrong-but-textually-present scrub (inverted condition, wrong index, wrong RingSize comparison) would still pass. The companion behavioral test + "simulator harness" it cites are not in this PR. Add a behavioral table test: ring==2 keeps Sender+Data[0]; ring==3 with a crafted slot byte yields Sender=="" & Data[0]==0; entry.Payload identical in both. Keep the grep as a tripwire, not the proof.

🟠 Address now via documentation (the PR's deferred items)

4. Pre-upgrade persisted history is never re-scrubbed. DevOps · high · CONFIRMED:556-623 (checkpoint sync) + :1197-1203 (persist)
The scrub is decode-time only. Normal SyncHistory resumes from entries[i].TopoHeight+1 and never re-decodes entries at/before the checkpoint (:609-613). Every ring>2 transfer stored before upgrading keeps its guessed Sender and unscrubbed Data[0], served on every Get_Transfers. The guarantee is real only for blocks decoded post-upgrade — a caveat currently living only in commit 4e9de564's message, not in code or API docs. Minimum: state it in the Entry/SenderVerified doc comment + release notes. Better: a one-time rescan or read-path scrub.

5. Two operational corollaries of #4 DevOps · medium · CONFIRMED ×2:

  • Downgrade is non-monotonic — rolling back to an old binary + any reorg-driven re-sync re-decodes ring>2 transfers with the old unscrubbed code, dirtying previously-clean entries. Treat the upgrade as one-way for the privacy property.
  • Backfill trapSenderVerified/RingSize are new fields, so old persisted entries deserialize to false/0, indistinguishable from "genuinely unknown." Any migration must re-derive RingSize from chain, not read the persisted 0.

6. Entry.String() renders a bare empty Sender: line. UI/UX · high · CONFIRMEDwallet_rpc.go:117
The in-repo renderer (same struct the PR edits) still prints Sender: %s unconditionally and never references the new fields. A scrubbed transfer prints Sender: (blank) — reads as a decode bug, not an intentional refusal, the exact "looks broken" outcome the design meant to avoid. Render e.g. Sender: unknown (unverifiable, ring size N) when !SenderVerified.

🟡 Track / minor (verified)

  • Scrub block duplicated verbatim across both receiver sites Dev · low · CONFIRMED (:1079-1096:1133-1150). The natural DRY helper would break the grep test (it hard-codes "≥2 textual matches"), so the duplication is currently load-bearing for the test — resolve the grep-vs-DRY tension consciously.
  • Grep guard is brittle QE · medium · CONFIRMED: replacing []byte{0x00} with a named constant leaves behavior identical but fails the build with a security-flavored error. Loosen the regexps, or make the failure text say "behavior may be fine — confirm, then update guard."
  • Redundant exported_payload[:] at :1096 Dev · low — drop [:] to match :1150.

✅ Cleared (phantom concerns ruled out — don't re-flag)

  • Slice aliasing: append([]byte{0x00}, …[1:]…) allocates a fresh backing array; the source payload is never mutated. CONFIRMED safe.
  • ProcessPayload regression: it parses e.Payload (the untouched [1:] slice), never e.Data, so zeroing Data[0] cannot corrupt arg parsing. CONFIRMED safe.
  • "Blank sender looks identical to a parse failure" UI/UX · medium · REFUTED: PayloadError is non-empty on decode failures and RingSize>2 on scrubs, so a UI can distinguish them. Only the narrower withheld-vs-pre-upgrade-stale ambiguity is real (folds into Pr/blocktime-check #4/fix(simulator): implement missing CLI commands and align help/completer #5).
  • Empty-payload payload[0] panic is pre-existing, not introduced; the new [1:] slicing runs only after the existing [0] read succeeds.

Per-role summary

The design deserves to merge; it needs the Publickeylist bound corrected, the self/send flag reconciled, one behavioral test, and the non-retroactive caveat moved out of the commit message into the API docs — all on lines already in scope.

…l scrub test

Addresses the DEROFDN#21 six-perspective review:

- Self-send sites set SenderVerified=true + RingSize (both decode arms): a
  wallet-authored tx has a certain sender at any ring size, so a contract-
  following consumer no longer distrusts the user's own sends. (review DEROFDN#2)
- Add Test_Attribution_Scrub_Behavior: drives the real receiver decode on a
  sim chain and asserts the scrub effect (ring2 keeps Sender+Data[0]; ring>2
  blanks Sender + zeroes Data[0]). Rebuilds until the receiver lands in a
  non-zero ring slot so the Data[0]==0 assertion has teeth. Mutation-checked.
  The source-grep guard is kept as a tripwire. (review DEROFDN#3)
- Document the (RingSize, SenderVerified, Sender) renderer truth table on the
  field; drop the redundant exported_payload[:] slice. (review DEROFDN#6, DEROFDN#9)

The <=->< bounds guard is sufficient: rpc.NewAddress panics on an
undecompressable ring key before the Publickeylist rebuild, so
len(Publickeylist)==RingSize holds and the index cannot go out of range. (review #1)
DHEBP added a commit to DHEBP/derohe that referenced this pull request Jun 24, 2026
…anon

Addresses the DEROFDN#22 review:

- curatedRingCandidates canonicalizes the sender, recipient, and every
  preferred decoy to its base address before the distinctness/dedup checks,
  and the ring carries the base form. A ring member is identified by its
  pubkey, not its address string, so an integrated/payment-id encoding of an
  account already in the ring (sender, recipient, or another decoy) no longer
  slips the string-keyed checks and lands a duplicate pubkey that consensus
  rejects AFTER signing. Test_CuratedRing_AltEncoding_NoDuplicate_A2 covers
  recipient / sender / decoy-vs-decoy / cross-network, each mutation-proven. (review #1)
- Anonymous attribution now fails closed at ring size < 4 (no decoy slot)
  instead of silently falling through to honest attribution. (review DEROFDN#8)
- Port the unverified-attribution export scrub + self-send flag into the decode
  arms (this stack predates it); behavioral scrub test + an honest-byte guard
  that asserts honest mode writes the receiver slot (closes the grep guard's
  reassignment blind spot). (review DEROFDN#3, DEROFDN#9)

The bounds guard is sufficient for the same reason as DEROFDN#21: bad ring keys panic
at NewAddress before the Publickeylist rebuild, so len==RingSize. (review DEROFDN#4)
DHEBP added a commit to DHEBP/derohe that referenced this pull request Jun 24, 2026
Add an advanced-only AttributionSelf mode that points the receiver-readable
attribution byte at the sender's own ring slot (witness_index[0]) — the
attribution field's original purpose, offered explicitly rather than by
default. The receiver-pointed default stays untouched and un-overridable;
self-pointing is reachable ONLY via the named AttributionSelf value.

Engine (walletapi/transaction_build.go):
- AttributionMode gains AttributionSelf; one build branch writes
  witness_index[0]. Works at any ring size (slot 0 always exists), so it is
  intentionally not subject to the anonymous ring<4 fail-closed guard.
- Guardrail comment reframed: honest/default can never self-point; the sender
  slot is reachable only via the explicit value.

CLI (cmd/dero-wallet-cli):
- session attribution flag becomes a 3-way mode; menu option 2 cycles
  Default -> Anonymous -> Self -> Default. Landing on Self triggers a
  mandatory, permanent-self-doxx warning; declining leaves the mode unchanged.
- Self bypasses the anonymous ring-size downgrade and is reported verbatim.
  The result line is ring-2-aware: at ring 2 the sender is already
  structurally provable (decode overrides sender_idx; SenderVerified=true for
  honest too), so the written byte adds nothing over honest; at ring>2 it is a
  permanent on-chain record recoverable by a non-blanking/future reader.
- --anonymous seeds Anonymous only; Self is never reachable from a flag.

Tests:
- Test_AttributionSelf_WritesSenderSlot: build-path proof the byte points at
  the sender slot (mutation-proven: flip to [1] -> RED).
- Test_AttributionSelf_EndToEnd_ScrubHolds: build->broadcast->mined->decode
  proof the DEROFDN#21 scrub still blanks the byte for a current wallet at ring>2.
- 3-way cycle, downgrade-bypass, result-line, and label-suffix unit tests.

The DEROFDN#21 scrub blanks the byte for current recipient wallets at ring>2 but does
not make self "safe" — the raw on-chain byte is permanent. That permanence is
why the warning is mandatory.
DHEBP added a commit to DHEBP/derohe that referenced this pull request Jul 2, 2026
Add an advanced-only AttributionSelf mode that points the receiver-readable
attribution byte at the sender's own ring slot (witness_index[0]) — the
attribution field's original purpose, offered explicitly rather than by
default. The receiver-pointed default stays untouched and un-overridable;
self-pointing is reachable ONLY via the named AttributionSelf value.

Engine (walletapi/transaction_build.go):
- AttributionMode gains AttributionSelf; one build branch writes
  witness_index[0]. Works at any ring size (slot 0 always exists), so it is
  intentionally not subject to the anonymous ring<4 fail-closed guard.
- Guardrail comment reframed: honest/default can never self-point; the sender
  slot is reachable only via the explicit value.

CLI (cmd/dero-wallet-cli):
- session attribution flag becomes a 3-way mode; menu option 2 cycles
  Default -> Anonymous -> Self -> Default. Landing on Self triggers a
  mandatory, permanent-self-doxx warning; declining leaves the mode unchanged.
- Self bypasses the anonymous ring-size downgrade and is reported verbatim.
  The result line is ring-2-aware: at ring 2 the sender is already
  structurally provable (decode overrides sender_idx; SenderVerified=true for
  honest too), so the written byte adds nothing over honest; at ring>2 it is a
  permanent on-chain record recoverable by a non-blanking/future reader.
- --anonymous seeds Anonymous only; Self is never reachable from a flag.

Tests:
- Test_AttributionSelf_WritesSenderSlot: build-path proof the byte points at
  the sender slot (mutation-proven: flip to [1] -> RED).
- Test_AttributionSelf_EndToEnd_ScrubHolds: build->broadcast->mined->decode
  proof the DEROFDN#21 scrub still blanks the byte for a current wallet at ring>2.
- 3-way cycle, downgrade-bypass, result-line, and label-suffix unit tests.

The DEROFDN#21 scrub blanks the byte for current recipient wallets at ring>2 but does
not make self "safe" — the raw on-chain byte is permanent. That permanence is
why the warning is mandatory.
…etroactive scrub (review #1/DEROFDN#4/DEROFDN#5/DEROFDN#6)

DEROFDN#6 — Entry.String() printed "Sender: %s" unconditionally: a scrubbed ring>2
transfer rendered a bare "Sender: " line that reads as a decode bug — the
exact "looks broken" outcome the design meant to avoid, contradicting the
truth table documented on SenderVerified in the same file. The Sender line
now follows the table: verified -> the address; scrubbed (no payload
error) -> "unknown (unverifiable, ring size N)"; decode failure -> a
distinct "unknown (payload decode failed)". Blast radius: two CLI debug
dumps (%+v in show_transfers error paths); JSON marshalling untouched.
Pinned by rpc/wallet_rpc_test.go (offline, sub-second): all three arms,
plus forbids the bare line and scrub/decode-failure conflation.

DEROFDN#4/DEROFDN#5 — the non-retroactive caveat lived only in commit 4e9de56's message.
Now in the docs consumers read: SenderVerified's doc comment states the
scrub is decode-time only (pre-upgrade entries keep their guessed Sender
and unscrubbed Data[0], served as-is by Get_Transfers; the guarantee holds
only for blocks decoded post-upgrade), the upgrade is one-way for the
privacy property, and pre-upgrade entries deserialize
SenderVerified=false, RingSize=0 — indistinguishable from a scrub without
re-decoding, so a migration MUST re-derive RingSize from chain data (a
read-path scrub keyed on persisted fields would wrongly blank the wallet's
own verified sends — why the rescan is a follow-up, not this commit).
RingSize's doc gains the "0 = pre-upgrade/unknown, not a real ring" note;
the GetTransfers handler doc points RPC consumers at the contract.

#1 hardening — both decode arms bounded sender_idx against
Statement.RingSize while indexing Statement.Publickeylist. Safe today
(len(Publickeylist)==RingSize holds at that point), but the guard now
bounds against the slice actually indexed, so it survives any future break
of that invariant.

Existing pins green: the scrub-behavior sim test and the source-grep guard
(its regexes key on the scrub lines, untouched here).
@DHEBP

DHEBP commented Jul 2, 2026

Copy link
Copy Markdown
Author

Thanks — all tiers addressed: #2/#3 landed earlier in b6e8024 (never replied — apologies), #1/#4/#5/#6 in 6446d66. #7#9 tracked.

#2 (self-send distrust): fixed in b6e8024 — self/outgoing sends reconcile to SenderVerified: true at decode; the "self / true / own addr" row is real.

#3 (grep-only test): fixed in b6e8024attribution_scrub_behavior_test.go runs the decode path on a sim chain: ring 2 keeps Sender+Data[0]; ring>2 yields Sender=="" + zeroed slot byte; payload survives both. Grep guard stays as the tripwire, per your framing.

#1 (bounds vs wrong collection): took the hardening — both decode arms now bound sender_idx against len(Statement.Publickeylist), the slice actually indexed; survives any future break of the len==RingSize invariant.

#4/#5 (non-retroactive caveat): in the docs consumers read now. SenderVerified doc: scrub is decode-time only; pre-upgrade entries keep their guessed Sender/unscrubbed Data[0] and are served as-is; the upgrade is one-way; pre-upgrade entries deserialize false/0, so a migration must re-derive RingSize from chain. RingSize doc: 0 = pre-upgrade/unknown, not a real ring. GetTransfers handler doc points at the contract. No release-notes file in this repo — this comment + the PR description carry it. The rescan ("better" option) is a deliberate follow-up: keyed on persisted fields it would blank the wallet's own verified sends (also false/0); it needs your chain re-derivation.

#6 (bare Sender: line): Entry.String() follows its own truth table — verified → address; scrubbed → unknown (unverifiable, ring size N) (your wording); decode failure → distinct unknown (payload decode failed). Pinned by rpc/wallet_rpc_test.go: all three arms, forbids the bare line and scrub/failure conflation.

Heads-up: #22/#23 fork from 1a0e0ba mid-stack and carry their own review cycle; coherence resolves at the #21#22#23 merge order.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants