package encoder import ( "bytes" "crypto/rand" "encoding/base64" "encoding/binary" "encoding/hex" "os" "strings" "testing" "golang.org/x/crypto/nacl/box" ) func testKeyring(t *testing.T) string { t.Helper() path := t.TempDir() + "/pubring.mix" var content strings.Builder for i, name := range []string{"entry", "middle", "exit"} { keyID := strings.Repeat(string(rune('a'+i)), 32) publicKey := strings.Repeat(string(rune('d'+i)), 64) content.WriteString(name + " " + name + "@example.org " + keyID + " 4:0.2c E 2025-01-01 2099-12-31\n\n") content.WriteString("-----Begin Mix Key-----\n" + keyID + "\n" + publicKey + "\n-----End Mix Key-----\n") } if err := os.WriteFile(path, []byte(content.String()), 0600); err != nil { t.Fatal(err) } return path } func TestValidateEmail(t *testing.T) { r := Request{Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, To: "user@example.org", Body: "hello"} if err := Validate(r); err != nil { t.Fatal(err) } } func TestValidateRejectsPlainIncompleteRequest(t *testing.T) { r := Request{Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, To: "bad", Body: "hello"} if err := Validate(r); err == nil { t.Fatal("expected invalid recipient") } } func TestValidateUsenetRequiresGatewayRecipient(t *testing.T) { r := Request{Kind: Usenet, PublicKeyring: "/tmp/pubring.mix", Chain: []string{"entry"}, Newsgroup: "misc.test", Body: "hello"} if err := Validate(r); err == nil { t.Fatal("expected missing Usenet gateway recipient to be rejected") } r.To = "mail2news@example.org" if err := Validate(r); err != nil { t.Fatalf("expected valid Usenet request: %v", err) } } func TestComposeUsenetGatewayHeaders(t *testing.T) { plain, err := composeMessage(Request{ Kind: Usenet, To: "mail2news@example.org", Subject: "test", Newsgroup: "misc.test", Body: "hello", References: "", }) if err != nil { t.Fatal(err) } text := string(plain) if !strings.Contains(text, "To: mail2news@example.org\n") || !strings.Contains(text, "Newsgroups: misc.test\n") || !strings.Contains(text, "References: \n") || !strings.Contains(text, "In-Reply-To: \n") { t.Fatalf("missing Usenet delivery headers: %q", text) } } func TestComposeReferencesChainUsesLastIDAsParent(t *testing.T) { plain, err := composeMessage(Request{ Kind: Usenet, To: "mail2news@example.org", Subject: "test", Newsgroup: "misc.test", Body: "hello", References: " ", }) if err != nil { t.Fatal(err) } text := string(plain) if !strings.Contains(text, "References: \n") || !strings.Contains(text, "In-Reply-To: \n") { t.Fatalf("thread headers were not normalized: %q", text) } } func TestValidateReferences(t *testing.T) { tests := []struct { name string references string valid bool }{ {name: "empty", valid: true}, {name: "whitespace only", references: " "}, {name: "parent", references: "", valid: true}, {name: "thread chain", references: " ", valid: true}, {name: "missing brackets", references: "parent@example.org"}, {name: "missing local part", references: "<@example.org>"}, {name: "multiple at signs", references: ""}, {name: "embedded newline", references: "\n"}, {name: "header label", references: "References: "}, {name: "too long", references: "<" + strings.Repeat("a", maxReferencesBytes) + "@example.org>"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { request := Request{ Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, To: "user@example.org", Body: "hello", References: test.references, } err := Validate(request) if test.valid && err != nil { t.Fatalf("expected valid References, got %v", err) } if !test.valid && err == nil { t.Fatal("expected invalid References to be rejected") } }) } } func TestValidateReplyTo(t *testing.T) { request := Request{ Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, To: "user@example.org", Body: "hello", ReplyTo: "Pseudonym ", } if err := Validate(request); err != nil { t.Fatalf("expected valid Reply-To, got %v", err) } request.ReplyTo = "not an address" if err := Validate(request); err == nil { t.Fatal("expected invalid Reply-To to be rejected") } } func TestEncodeProducesYAMNArmor(t *testing.T) { keyring := testKeyring(t) result, err := Encode(Request{ Kind: Email, PublicKeyring: keyring, Entry: "entry", Chain: []string{"entry"}, From: "Anonymous ", To: "user@example.org", Subject: "test", Body: "hello", }) if err != nil { t.Fatal(err) } if result.EntryAddress != "entry@example.org" { t.Fatalf("unexpected entry address: %q", result.EntryAddress) } text := string(result.Envelope) if !strings.Contains(text, "-----BEGIN REMAILER MESSAGE-----") || !strings.Contains(text, "-----END REMAILER MESSAGE-----") { t.Fatal("missing YAMN armor markers") } lines := strings.Split(text, "\n") start := 0 for i, line := range lines { if line == "-----BEGIN REMAILER MESSAGE-----" { start = i + 3 break } } var encoded strings.Builder for _, line := range lines[start:] { if line == "" || strings.HasPrefix(line, "-----END") { break } encoded.WriteString(line) } packet, err := base64.StdEncoding.DecodeString(encoded.String()) if err != nil { t.Fatal(err) } if len(packet) != messageBytes { t.Fatalf("unexpected packet size: got %d, want %d", len(packet), messageBytes) } } func TestEncodeMultiHop(t *testing.T) { result, err := Encode(Request{ Kind: Email, PublicKeyring: testKeyring(t), Entry: "entry", Chain: []string{"entry", "middle", "exit"}, To: "user@example.org", Body: "hello", }) if err != nil { t.Fatal(err) } if len(result.Envelope) == 0 || result.EntryAddress != "entry@example.org" { t.Fatal("multi-hop envelope was not produced") } } func TestEncodeMultiHopDecodesAtEveryRemailer(t *testing.T) { keyring, secretKeys := testDecodeKeyring(t) request := Request{ Kind: Email, PublicKeyring: keyring, Entry: "entry", Chain: []string{"entry", "middle", "exit"}, From: "Anonymous ", To: "user@example.org", Subject: "three-hop compatibility", Body: "hello through three remailers", } result, err := Encode(request) if err != nil { t.Fatal(err) } packet := decodeArmoredPacket(t, result.Envelope) wantPlain, err := composeMessage(request) if err != nil { t.Fatal(err) } for hop, name := range request.Chain { data := openHeaderForTest(t, packet[:headerBytes], secretKeys[name]) if data[0] != 2 { t.Fatalf("hop %d (%s): unexpected packet version %d", hop, name, data[0]) } if !bytes.Equal(data[117:149], antiTag(packet)) { t.Fatalf("hop %d (%s): anti-tag mismatch", hop, name) } switch data[1] { case 0: if hop == len(request.Chain)-1 { t.Fatalf("hop %d (%s): exit encoded as intermediate", hop, name) } shiftHeadersUpForTest(packet) encryptAll(packet, data[19:51], data[53:65]) case 1: if hop != len(request.Chain)-1 { t.Fatalf("hop %d (%s): intermediate encoded as exit", hop, name) } bodyLength := int(binary.LittleEndian.Uint32(data[87:91])) if bodyLength != len(wantPlain) { t.Fatalf("exit body length: got %d, want %d", bodyLength, len(wantPlain)) } body := aesCTR(packet[maxChainLength*headerBytes:], data[19:51], data[53:69]) if !bytes.Equal(body[:bodyLength], wantPlain) { t.Fatal("exit plaintext does not match the encoded message") } default: t.Fatalf("hop %d (%s): unknown packet type %d", hop, name, data[1]) } } } func testDecodeKeyring(t *testing.T) (string, map[string]*[32]byte) { t.Helper() path := t.TempDir() + "/pubring.mix" secretKeys := make(map[string]*[32]byte) var content strings.Builder for i, name := range []string{"entry", "middle", "exit"} { publicKey, secretKey, err := box.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } keyID := bytes.Repeat([]byte{byte(i + 1)}, 16) content.WriteString(name + " " + name + "@example.org " + hex.EncodeToString(keyID) + " 4:0.2c E 2025-01-01 2099-12-31\n\n") content.WriteString("-----Begin Mix Key-----\n" + hex.EncodeToString(keyID) + "\n" + hex.EncodeToString(publicKey[:]) + "\n-----End Mix Key-----\n") secretKeys[name] = secretKey } if err := os.WriteFile(path, []byte(content.String()), 0600); err != nil { t.Fatal(err) } return path, secretKeys } func decodeArmoredPacket(t *testing.T, envelope []byte) []byte { t.Helper() lines := strings.Split(string(envelope), "\n") start := -1 for i, line := range lines { if line == "-----BEGIN REMAILER MESSAGE-----" { start = i + 3 break } } if start < 0 { t.Fatal("missing armored packet start") } var encoded strings.Builder for _, line := range lines[start:] { if line == "" || strings.HasPrefix(line, "-----END") { break } encoded.WriteString(line) } packet, err := base64.StdEncoding.DecodeString(encoded.String()) if err != nil { t.Fatal(err) } if len(packet) != messageBytes { t.Fatalf("unexpected packet size: got %d, want %d", len(packet), messageBytes) } return packet } func openHeaderForTest(t *testing.T, header []byte, recipientSecret *[32]byte) []byte { t.Helper() var senderPublic [32]byte copy(senderPublic[:], header[16:48]) var nonce [24]byte copy(nonce[:], header[48:72]) data, ok := box.Open(nil, header[72:248], &nonce, &senderPublic, recipientSecret) if !ok { t.Fatal("header authentication failed") } if len(data) != encHeadBytes { t.Fatalf("unexpected decoded header size: got %d, want %d", len(data), encHeadBytes) } return data } func shiftHeadersUpForTest(packet []byte) { headersEnd := maxChainLength * headerBytes copy(packet, packet[headerBytes:headersEnd]) clear(packet[headersEnd-headerBytes : headersEnd]) }