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
37 changes: 35 additions & 2 deletions rpc/wallet_rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,32 @@ type Entry struct {
Payload_RPC Arguments `json:"payload_rpc,omitempty"`

// these fields are only valid based on payload type and if payload could be successfully parsed and will by default be equal to zero values
Sender string `json:"sender"`
Sender string `json:"sender"`
// SenderVerified is true when entry.Sender can be trusted: either attribution is
// protocol-pinned (ring size 2, where the counterparty is necessarily the sender)
// or this wallet authored the transaction (an outgoing/self send, certain at any
// ring size). For an incoming ring size > 2 transfer the sender chose the
// unauthenticated attribution byte and entry.Sender MUST NOT be trusted.
//
// Renderer truth table (RingSize, SenderVerified, Sender):
// ring 2 / true / addr -> trusted counterparty; show it
// self / true / own addr -> our own send; show it
// ring>2 / false / "" -> withheld (scrubbed); show "unknown (unverifiable, ring N)"
// any / false / "" + PayloadError != "" -> decode failed (distinct from a scrub)
//
// NON-RETROACTIVE: the scrub runs at DECODE time only. Entries persisted before the
// wallet upgraded keep their guessed Sender and unscrubbed Data[0], and are served
// as-is (Get_Transfers included) — the guarantee holds only for blocks decoded
// post-upgrade. The upgrade is one-way for the privacy property: pre-upgrade entries
// deserialize SenderVerified=false, RingSize=0 (zero values), which is
// indistinguishable from a scrub without re-decoding — so a migration MUST re-derive
// RingSize from chain data, never trust the persisted 0 (a read-path scrub keyed on
// these fields would wrongly blank the wallet's own verified sends).
SenderVerified bool `json:"sender_verified"`
// RingSize is the ring size of the payload this entry was decoded from. 0 means
// unknown — typically an entry persisted before the attribution upgrade — NOT a real
// ring; see the non-retroactive caveat on SenderVerified.
RingSize uint64 `json:"ringsize"`
DestinationPort uint64 `json:"dstport"`
SourcePort uint64 `json:"srcport"`
}
Expand All @@ -108,7 +133,15 @@ func (e Entry) String() string {
if !e.Coinbase {
fmt.Fprintf(&b, "PayloadType : %d\n", e.PayloadType)
if e.PayloadType == 0 {
fmt.Fprintf(&b, "Sender: %s\n", e.Sender)
// the Sender line follows the truth table on SenderVerified above: a scrubbed
// attribution must read as a deliberate refusal, distinct from a decode failure.
if e.SenderVerified {
fmt.Fprintf(&b, "Sender: %s\n", e.Sender)
} else if e.PayloadError == "" {
fmt.Fprintf(&b, "Sender: unknown (unverifiable, ring size %d)\n", e.RingSize)
} else {
fmt.Fprintf(&b, "Sender: unknown (payload decode failed)\n")
}
if e.PayloadError == "" {
args, _ := e.ProcessPayload()
fmt.Fprintf(&b, "DestPort: %016x\n", e.DestinationPort)
Expand Down
52 changes: 52 additions & 0 deletions rpc/wallet_rpc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright 2017-2021 DERO Project. All rights reserved.
// Use of this source code in any form is governed by RESEARCH license.
// license can be found in the LICENSE file.

package rpc

import (
"strings"
"testing"
)

// Test_Entry_String_SenderTruthTable pins Entry.String()'s Sender line to the
// renderer truth table documented on SenderVerified (PR #21 review finding #6):
// a scrubbed attribution must read as a deliberate refusal — never a bare
// "Sender: " line that looks like a decode bug — and stay distinct from an
// actual decode failure.
func Test_Entry_String_SenderTruthTable(t *testing.T) {
cases := []struct {
name string
e Entry
want string
forbid string
}{
{
name: "verified sender is shown",
e: Entry{Incoming: true, PayloadType: 0, Sender: "deto1qyexampleaddress", SenderVerified: true, RingSize: 2},
want: "Sender: deto1qyexampleaddress\n",
},
{
name: "scrubbed ring>2 renders unverifiable with ring size",
e: Entry{Incoming: true, PayloadType: 0, Sender: "", SenderVerified: false, RingSize: 4},
want: "Sender: unknown (unverifiable, ring size 4)\n",
forbid: "Sender: \n",
},
{
name: "decode failure renders distinctly from a scrub",
e: Entry{Incoming: true, PayloadType: 0, Sender: "", SenderVerified: false, RingSize: 4, PayloadError: "short payload"},
want: "Sender: unknown (payload decode failed)\n",
forbid: "unverifiable",
},
}

for _, c := range cases {
out := c.e.String()
if !strings.Contains(out, c.want) {
t.Fatalf("%s: rendered output missing %q:\n%s", c.name, c.want, out)
}
if c.forbid != "" && strings.Contains(out, c.forbid) {
t.Fatalf("%s: rendered output must not contain %q:\n%s", c.name, c.forbid, out)
}
}
}
55 changes: 55 additions & 0 deletions walletapi/attribution_export_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package walletapi

import (
"os"
"regexp"
"testing"
)

// Test_AttributionExportGuard_UnverifiedSlotByteScrubbed locks the export-boundary
// scrub for an unverified sender attribution (ring > 2).
//
// When a received transfer's attribution is unverified, payload[0] is a sender-chosen,
// unauthenticated slot index. It must NOT survive into any exported Entry field: the raw
// byte re-derives the claimed sender via the public Publickeylist even after entry.Sender
// is blanked, because entry.Data carries the whole payload and Publickeylist is public.
//
// This guard pairs with Test_AttributionGuard_HonestWritesReceiverIndex (the send-side
// guardrail). It is a source-level guard (greps the two receive-as-receiver decode sites)
// so it needs no wallet/daemon harness; the behavioral end-to-end proof lives in the
// simulator harness. Its job is to make a future refactor that drops the scrub fail loud.
func Test_AttributionExportGuard_UnverifiedSlotByteScrubbed(t *testing.T) {
src, err := os.ReadFile("daemon_communication.go")
if err != nil {
t.Fatalf("cannot read daemon_communication.go: %v", err)
}

// The scrub must blank entry.Sender when the attribution is unverified.
blankSender := regexp.MustCompile(`if\s+!entry\.SenderVerified\s*\{[\s\S]*?entry\.Sender\s*=\s*""`)
if n := len(blankSender.FindAll(src, -1)); n < 2 {
t.Fatalf("EXPORT SCRUB MISSING: expected entry.Sender blanked under `if !entry.SenderVerified` at "+
"both receive sites (CBOR + CBOR_V2); found %d. An unverified attribution must not export a "+
"claimed sender string.", n)
}

// The scrub must zero the leading slot byte in the payload copy that feeds entry.Data,
// so the byte cannot re-derive the sender via Publickeylist. Matches both the CBOR site
// (`tx.Payloads[t].RPCPayload[1:]`) and the CBOR_V2 site (the local `payload[1:]`).
zeroByte := regexp.MustCompile(`append\(\[\]byte\{0x00\},\s*[\w.\[\]]+\[1:\]\.\.\.\)`)
if n := len(zeroByte.FindAll(src, -1)); n < 2 {
t.Fatalf("EXPORT SCRUB MISSING: expected the attribution slot byte zeroed (`append([]byte{0x00}, "+
"...[1:]...)`) in the exported payload at both receive sites; found %d. Without this, "+
"entry.Data[0] still re-derives the claimed sender through the public Publickeylist.", n)
}

// At the two receive-as-receiver sites, entry.Data must be fed from the sanitized
// `exported_payload`, never the raw payload. (The two SELF-side decode sites — where the
// wallet decodes a tx IT sent — legitimately keep the raw payload and are not gated by
// SenderVerified; they are out of scope and intentionally not matched here.)
sanitizedExport := regexp.MustCompile(`entry\.Data\s*=\s*append\(entry\.Data,\s*exported_payload`)
if n := len(sanitizedExport.FindAll(src, -1)); n < 2 {
t.Fatalf("EXPORT SCRUB BYPASSED: expected entry.Data fed from the sanitized `exported_payload` at "+
"both receive sites; found %d. Feeding the raw payload would export an unverified slot byte "+
"that re-derives the claimed sender via the public Publickeylist.", n)
}
}
Loading