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..ddac2b1 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,14 +1478,22 @@ 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 +// 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 err := sendCode(c, RejectCodeDuplicate); err != nil { - return false, err + if challengeCompleted && allLocalDup { + return RejectCodeDuplicate } - return true, nil + if initialCode == AcceptCodeSkipData { + return AcceptCodeSkipData + } + return AcceptCodeContinue } func abortConn(c net.Conn) { @@ -1547,18 +1532,62 @@ 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) - return + // §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 + } } - skipData := header.InitialResponseCode == AcceptCodeSkipData + code := resolvePostChallengeCode(header.InitialResponseCode, header.ChallengeCompleted, allLocalDup) + skipData := false - if skipData { + switch code { + 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 + 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: + 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: + 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..f0d3a58 100644 --- a/src/host_test.go +++ b/src/host_test.go @@ -511,28 +511,37 @@ func TestReadAttachmentHeadersRejectsReservedAttachmentBits(t *testing.T) { } } -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) - } +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}, - c2 := &testConn{} - handled, err = respondGlobalDuplicateIfNeeded(c2, true, false) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if handled { - t.Fatalf("expected handled=false") + // 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}, } - if len(c2.Bytes()) != 0 { - t.Fatalf("expected no bytes written, got %v", c2.Bytes()) + 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)) + } + }) } }