From fd86e3eff517051425d2b25fae7c566106af1543 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Fri, 17 Apr 2026 20:42:02 +0800 Subject: [PATCH 1/2] v0.3.1 compliant, non-accept after challenge, duplicate before continue --- SPEC.md | 32 ++++++++-------- src/defs.go | 2 +- src/host.go | 99 ++++++++++++++++++++++++++---------------------- src/host_test.go | 26 ------------- 4 files changed, 72 insertions(+), 87 deletions(-) diff --git a/SPEC.md b/SPEC.md index 7042393..6031b9a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -153,7 +153,7 @@ Per-recipient codes (one byte per recipient on this host, in message order): ## 9. Domain Resolution -Resolve `fmsg.` for A/AAAA records. The sender's domain is: +Resolve ``fmsg.`` for A/AAAA records. The sender's domain is: - The domain of _add to from_ when _has add to_ is set. - The domain of _from_ otherwise. @@ -175,7 +175,7 @@ One message per connection. Two TCP connections used: Connection 1 (message tran Host A delivers iff _from_ or _add to from_ belongs to Host A's domain. For each unique recipient domain: -1. Resolve recipient domain IPs via `fmsg.`. Connect to first responsive IP (Connection 1). Retry with backoff if unreachable. +1. Resolve recipient domain IPs via ``fmsg.``. Connect to first responsive IP (Connection 1). Retry with backoff if unreachable. 2. Register the message header hash and Host B's IP in an outgoing record (for matching challenges). 3. Transmit the message header on Connection 1. 4. Wait for response. During this wait, be ready to handle a CHALLENGE on Connection 2 (see §10.5). @@ -206,32 +206,34 @@ Host A delivers iff _from_ or _add to from_ belongs to Host A's domain. For each - DELTA > MAX_MESSAGE_AGE → respond code 7, close. - DELTA < −MAX_TIME_SKEW → respond code 8, close. 7. Evaluate pid / add-to: - - **No pid, no add-to** (new thread): respond 64 (continue). + - **No pid, no add-to** (new thread): proceed. - **pid set, no add-to** (reply): - Verify parent stored (§11). Not found → respond code 6, close. - Parent time − MAX_TIME_SKEW must be before incoming time. Fail → respond code 9, close. - _from_ must be a participant of the parent. Fail → respond code 1, close. - - Respond 64 (continue). - **add-to set** (adding recipients): - pid MUST also be set. Fail → respond code 1, close. - Check if parent stored (§11): - **Stored**: check time travel (code 9 if fail). - - If any _add to_ recipient belongs to Host B's domain → respond 65 (skip data), then per-recipient codes per §10.4. - - Otherwise → record add-to fields, respond 11 (accept add to), close. - - **Not stored**: respond 64 (continue) — treat as full message delivery. - -### 10.4 Receiving — Data Download and Per-Recipient Response - -1. If challenge was completed, use the message hash from the challenge response to check for duplicates across all recipients on Host B. If duplicate for all → respond code 10, close. -2. If code 65 was sent, skip to step 4 (data already stored). Otherwise download data + attachments (exactly declared sizes). -3. If challenge was completed, verify computed message hash matches the challenge response hash. For code 65, compute from received header + stored data. Mismatch → TERMINATE. -4. For each recipient on Host B's domain (in _to_ order, then _add to_ order), send one response byte: + - **Not stored**: treat as full message delivery. +8. Optionally issue a CHALLENGE on Connection 2 (see §10.5). + +### 10.4 Receiving — ACCEPT Response, Data Download and Per-Recipient Response + +1. If _add to_ set and parent verified stored in step 7: + - If any _add to_ recipient belongs to Host B's domain → respond 65 (skip data). + - Otherwise → record add-to fields, respond 11 (accept add to), close. +2. If challenge was completed, use the message hash from the challenge response to check for duplicates across all recipients on Host B. If duplicate for all → respond code 10, close. +3. Otherwise → respond 64 (continue). +4. If code 65 was sent, skip to step 6 (data already stored). Otherwise download data + attachments (exactly declared sizes). +5. If challenge was completed, verify computed message hash matches the challenge response hash. For code 65, compute from received header + stored data. Mismatch → TERMINATE. +6. For each recipient on Host B's domain (in _to_ order, then _add to_ order), send one response byte: - Already received → 103 (or 105). - Unknown address → 100 (or 105). - Quota exceeded → 101 (or 105). - Not accepting → 102 (or 105). - Otherwise → 200 (accept). -5. Close Connection 1. +7. Close Connection 1. ### 10.5 Challenge Flow diff --git a/src/defs.go b/src/defs.go index a44733c..084dc2e 100644 --- a/src/defs.go +++ b/src/defs.go @@ -47,7 +47,7 @@ type FMsgHeader struct { HeaderHash []byte ChallengeHash [32]byte ChallengeCompleted bool // True if challenge was initiated and completed - InitialResponseCode uint8 // Protocol response chosen after header validation (64/65) + InitialResponseCode uint8 // Protocol response chosen after header validation (11/64/65) Filepath string messageHash []byte } diff --git a/src/host.go b/src/host.go index 5e489e4..466d508 100644 --- a/src/host.go +++ b/src/host.go @@ -611,17 +611,8 @@ func handleAddToPath(c net.Conn, h *FMsgHeader) (*FMsgHeader, error) { h.Attachments[i].Filepath = parentMsg.Attachments[i].Filepath } } - if err := storeMsgHeaderOnly(h); err != nil { - if err2 := sendCode(c, RejectCodeUndisclosed); err2 != nil { - return h, err2 - } - return h, fmt.Errorf("add-to notification: storing header: %w", err) - } - if err := sendCode(c, AcceptCodeAddTo); err != nil { - return h, err - } - log.Printf("INFO: additional recipients received (code 11) for pid %s", hex.EncodeToString(h.Pid)) - return nil, nil + h.InitialResponseCode = AcceptCodeAddTo + return h, nil } func validatePidReplyPath(c net.Conn, h *FMsgHeader) error { @@ -1388,20 +1379,6 @@ func downloadMessage(c net.Conn, r io.Reader, h *FMsgHeader, skipData bool) erro } codes := make([]byte, len(addrs)) - if h.ChallengeCompleted { - allDup, err := allLocalRecipientsHaveMessageHash(h.ChallengeHash[:], addrs) - if err != nil { - return err - } - handled, err := respondGlobalDuplicateIfNeeded(c, h.ChallengeCompleted, allDup) - if err != nil { - return err - } - if handled { - return nil - } - } - createdPaths, err := prepareMessageData(r, h, skipData) if err != nil { return err @@ -1501,16 +1478,6 @@ func downloadMessage(c net.Conn, r io.Reader, h *FMsgHeader, skipData bool) erro return rejectAccept(c, codes) } -func respondGlobalDuplicateIfNeeded(c net.Conn, challengeCompleted, allDup bool) (bool, error) { - if !challengeCompleted || !allDup { - return false, nil - } - if err := sendCode(c, RejectCodeDuplicate); err != nil { - return false, err - } - return true, nil -} - func abortConn(c net.Conn) { if tcp, ok := c.(*net.TCPConn); ok { tcp.SetLinger(0) @@ -1547,18 +1514,60 @@ func handleConn(c net.Conn) { return } - // Send post-header response code (64 continue / 65 skip data). - if err := rejectAccept(c, []byte{header.InitialResponseCode}); err != nil { - log.Printf("ERROR: failed sending initial response to %s: %s", c.RemoteAddr().String(), err) - abortConn(c) + // §10.4 Step 1: Add-to handling (only when add-to set and parent verified stored) + skipData := false + switch header.InitialResponseCode { + case AcceptCodeAddTo: + // No local add-to recipients; store header and respond code 11, close. + if err := storeMsgHeaderOnly(header); err != nil { + log.Printf("ERROR: storing add-to header: %s", err) + _ = sendCode(c, RejectCodeUndisclosed) + abortConn(c) + return + } + if err := sendCode(c, AcceptCodeAddTo); err != nil { + log.Printf("ERROR: failed sending code 11 to %s: %s", c.RemoteAddr().String(), err) + abortConn(c) + return + } + log.Printf("INFO: additional recipients received (code 11) for pid %s", hex.EncodeToString(header.Pid)) + c.Close() return - } - - skipData := header.InitialResponseCode == AcceptCodeSkipData - - if skipData { + case AcceptCodeSkipData: + // Local add-to recipients exist; parent stored; skip data. + if err := sendCode(c, AcceptCodeSkipData); err != nil { + log.Printf("ERROR: failed sending code 65 to %s: %s", c.RemoteAddr().String(), err) + abortConn(c) + return + } + skipData = true log.Printf("INFO: sent code 65 (skip data) to %s", c.RemoteAddr().String()) - } else { + default: + // §10.4 Step 2: Duplicate check (before sending continue) + if header.ChallengeCompleted { + addrs := localRecipients(header) + allDup, err := allLocalRecipientsHaveMessageHash(header.ChallengeHash[:], addrs) + if err != nil { + log.Printf("ERROR: duplicate check failed for %s: %s", c.RemoteAddr().String(), err) + _ = sendCode(c, RejectCodeUndisclosed) + abortConn(c) + return + } + if allDup { + if err := sendCode(c, RejectCodeDuplicate); err != nil { + log.Printf("ERROR: failed sending code 10 to %s: %s", c.RemoteAddr().String(), err) + } + c.Close() + return + } + } + + // §10.4 Step 3: Continue + if err := sendCode(c, AcceptCodeContinue); err != nil { + log.Printf("ERROR: failed sending code 64 to %s: %s", c.RemoteAddr().String(), err) + abortConn(c) + return + } log.Printf("INFO: sent code 64 (continue) to %s", c.RemoteAddr().String()) } diff --git a/src/host_test.go b/src/host_test.go index bfb63d1..a5a1f80 100644 --- a/src/host_test.go +++ b/src/host_test.go @@ -510,29 +510,3 @@ func TestReadAttachmentHeadersRejectsReservedAttachmentBits(t *testing.T) { t.Fatalf("expected reject code %d, got %v", RejectCodeInvalid, got) } } - -func TestRespondGlobalDuplicateIfNeeded(t *testing.T) { - c := &testConn{} - handled, err := respondGlobalDuplicateIfNeeded(c, true, true) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !handled { - t.Fatalf("expected handled=true") - } - if got := c.Bytes(); len(got) != 1 || got[0] != RejectCodeDuplicate { - t.Fatalf("expected duplicate code %d, got %v", RejectCodeDuplicate, got) - } - - c2 := &testConn{} - handled, err = respondGlobalDuplicateIfNeeded(c2, true, false) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if handled { - t.Fatalf("expected handled=false") - } - if len(c2.Bytes()) != 0 { - t.Fatalf("expected no bytes written, got %v", c2.Bytes()) - } -} From 88ca7cdff541533f1cb48709ff002cfd507b0aab Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Fri, 17 Apr 2026 21:12:53 +0800 Subject: [PATCH 2/2] duplicate detection split out + test --- src/host.go | 66 +++++++++++++++++++++++++++++++----------------- src/host_test.go | 35 +++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/host.go b/src/host.go index 466d508..ddac2b1 100644 --- a/src/host.go +++ b/src/host.go @@ -1478,6 +1478,24 @@ func downloadMessage(c net.Conn, r io.Reader, h *FMsgHeader, skipData bool) erro return rejectAccept(c, codes) } +// resolvePostChallengeCode determines the initial response code to send after +// the optional challenge (§10.4). Code 11 (accept add-to) is returned as-is +// since it has no local recipients to duplicate-check. For the skip-data (65) +// and continue (64) paths, a completed challenge with all-local-duplicate +// produces code 10 (duplicate) instead. +func resolvePostChallengeCode(initialCode uint8, challengeCompleted bool, allLocalDup bool) uint8 { + if initialCode == AcceptCodeAddTo { + return AcceptCodeAddTo + } + if challengeCompleted && allLocalDup { + return RejectCodeDuplicate + } + if initialCode == AcceptCodeSkipData { + return AcceptCodeSkipData + } + return AcceptCodeContinue +} + func abortConn(c net.Conn) { if tcp, ok := c.(*net.TCPConn); ok { tcp.SetLinger(0) @@ -1514,9 +1532,26 @@ func handleConn(c net.Conn) { return } - // §10.4 Step 1: Add-to handling (only when add-to set and parent verified stored) + // §10.4: Determine initial response code after optional challenge. + // Code 11 (add-to, no local recipients) does not need a dup check. + // Codes 65 and 64 both require a dup check when challenge was completed. + allLocalDup := false + if header.ChallengeCompleted && header.InitialResponseCode != AcceptCodeAddTo { + addrs := localRecipients(header) + var err error + allLocalDup, err = allLocalRecipientsHaveMessageHash(header.ChallengeHash[:], addrs) + if err != nil { + log.Printf("ERROR: duplicate check failed for %s: %s", c.RemoteAddr().String(), err) + _ = sendCode(c, RejectCodeUndisclosed) + abortConn(c) + return + } + } + + code := resolvePostChallengeCode(header.InitialResponseCode, header.ChallengeCompleted, allLocalDup) skipData := false - switch header.InitialResponseCode { + + switch code { case AcceptCodeAddTo: // No local add-to recipients; store header and respond code 11, close. if err := storeMsgHeaderOnly(header); err != nil { @@ -1533,8 +1568,13 @@ func handleConn(c net.Conn) { log.Printf("INFO: additional recipients received (code 11) for pid %s", hex.EncodeToString(header.Pid)) c.Close() return + case RejectCodeDuplicate: + if err := sendCode(c, RejectCodeDuplicate); err != nil { + log.Printf("ERROR: failed sending code 10 to %s: %s", c.RemoteAddr().String(), err) + } + c.Close() + return case AcceptCodeSkipData: - // Local add-to recipients exist; parent stored; skip data. if err := sendCode(c, AcceptCodeSkipData); err != nil { log.Printf("ERROR: failed sending code 65 to %s: %s", c.RemoteAddr().String(), err) abortConn(c) @@ -1543,26 +1583,6 @@ func handleConn(c net.Conn) { skipData = true log.Printf("INFO: sent code 65 (skip data) to %s", c.RemoteAddr().String()) default: - // §10.4 Step 2: Duplicate check (before sending continue) - if header.ChallengeCompleted { - addrs := localRecipients(header) - allDup, err := allLocalRecipientsHaveMessageHash(header.ChallengeHash[:], addrs) - if err != nil { - log.Printf("ERROR: duplicate check failed for %s: %s", c.RemoteAddr().String(), err) - _ = sendCode(c, RejectCodeUndisclosed) - abortConn(c) - return - } - if allDup { - if err := sendCode(c, RejectCodeDuplicate); err != nil { - log.Printf("ERROR: failed sending code 10 to %s: %s", c.RemoteAddr().String(), err) - } - c.Close() - return - } - } - - // §10.4 Step 3: Continue if err := sendCode(c, AcceptCodeContinue); err != nil { log.Printf("ERROR: failed sending code 64 to %s: %s", c.RemoteAddr().String(), err) abortConn(c) diff --git a/src/host_test.go b/src/host_test.go index a5a1f80..f0d3a58 100644 --- a/src/host_test.go +++ b/src/host_test.go @@ -510,3 +510,38 @@ func TestReadAttachmentHeadersRejectsReservedAttachmentBits(t *testing.T) { t.Fatalf("expected reject code %d, got %v", RejectCodeInvalid, got) } } + +func TestResolvePostChallengeCode(t *testing.T) { + tests := []struct { + name string + initialCode uint8 + challengeCompleted bool + allLocalDup bool + want uint8 + }{ + // Add-to (code 11) path — never overridden by dup check. + {"add-to no challenge", AcceptCodeAddTo, false, false, AcceptCodeAddTo}, + {"add-to challenge no dup", AcceptCodeAddTo, true, false, AcceptCodeAddTo}, + {"add-to challenge all dup", AcceptCodeAddTo, true, true, AcceptCodeAddTo}, + + // Continue (code 64) path — dup check yields code 10 when all dup. + {"continue no challenge", AcceptCodeContinue, false, false, AcceptCodeContinue}, + {"continue challenge no dup", AcceptCodeContinue, true, false, AcceptCodeContinue}, + {"continue challenge all dup", AcceptCodeContinue, true, true, RejectCodeDuplicate}, + + // Skip-data (code 65) path — dup check yields code 10 when all dup. + {"skip-data no challenge", AcceptCodeSkipData, false, false, AcceptCodeSkipData}, + {"skip-data challenge no dup", AcceptCodeSkipData, true, false, AcceptCodeSkipData}, + {"skip-data challenge all dup", AcceptCodeSkipData, true, true, RejectCodeDuplicate}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolvePostChallengeCode(tt.initialCode, tt.challengeCompleted, tt.allLocalDup) + if got != tt.want { + t.Errorf("resolvePostChallengeCode(%d, %v, %v) = %d (%s), want %d (%s)", + tt.initialCode, tt.challengeCompleted, tt.allLocalDup, + got, responseCodeName(got), tt.want, responseCodeName(tt.want)) + } + }) + } +}