Skip to content
Merged
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
32 changes: 17 additions & 15 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ Per-recipient codes (one byte per recipient on this host, in message order):

## 9. Domain Resolution

Resolve `fmsg.<domain>` for A/AAAA records. The sender's domain is:
Resolve ``fmsg.<domain>`` 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.

Expand All @@ -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.<domain>`. Connect to first responsive IP (Connection 1). Retry with backoff if unreachable.
1. Resolve recipient domain IPs via ``fmsg.<domain>``. 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).
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/defs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
107 changes: 68 additions & 39 deletions src/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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())
Comment thread
markmnl marked this conversation as resolved.
} 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())
}

Expand Down
51 changes: 30 additions & 21 deletions src/host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -511,28 +511,37 @@ func TestReadAttachmentHeadersRejectsReservedAttachmentBits(t *testing.T) {
}
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR removes the only unit test that exercised the global duplicate response behavior, but does not add coverage for the new behavior (duplicate check now happening in handleConn before sending the initial accept code). Please add/adjust tests to cover at least:

  • global duplicate rejection is sent as the initial response (code 10) when all local recipients already have the challenged message hash
  • this holds for both the continue (64) and skip-data (65) paths, if those flows are intended to support global-duplicate rejection.
Suggested change
}
}
func TestHandleConnRejectsGlobalDuplicateAsInitialResponseOnContinuePath(t *testing.T) {
runHandleConnGlobalDuplicateInitialResponseTest(t, 64)
}
func TestHandleConnRejectsGlobalDuplicateAsInitialResponseOnSkipDataPath(t *testing.T) {
runHandleConnGlobalDuplicateInitialResponseTest(t, 65)
}
func runHandleConnGlobalDuplicateInitialResponseTest(t *testing.T, expectedPath byte) {
t.Helper()
host, conn, done := newHandleConnTestHarness(t)
t.Cleanup(func() {
conn.Close()
<-done
})
req := newChallengedMessageRequest(t)
seedAllLocalRecipientsWithChallengedHash(t, host, req)
go handleConn(conn)
writeHandleConnRequest(t, conn, req, expectedPath)
got := readHandleConnResponseCode(t, conn)
if got != 10 {
t.Fatalf("expected initial global duplicate reject code 10 on path %d, got %d", expectedPath, got)
}
}

Copilot uses AI. Check for mistakes.

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))
}
})
}
}
Loading