From cd58d593789c6facb1d05b7f255bc2c5f2e46010 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Mon, 3 Aug 2026 17:13:50 +0200 Subject: Initial import: n2usenet HTTPS/Nym Usenet gateway --- internal/submit/message.go | 153 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 internal/submit/message.go (limited to 'internal/submit/message.go') diff --git a/internal/submit/message.go b/internal/submit/message.go new file mode 100644 index 0000000..3bf8834 --- /dev/null +++ b/internal/submit/message.go @@ -0,0 +1,153 @@ +package submit + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/mail" + "os" + "os/exec" + "regexp" + "strings" + "time" +) + +var messageIDRE = regexp.MustCompile(`^<[^<>\s]+@[^<>\s]+>$`) + +func BuildMessage(sub Submission, recipient, messageIDDomain, identiconsCLI string, requireFace bool) (raw string, messageID string, err error) { + messageID, err = newMessageID(messageIDDomain) + if err != nil { + return "", "", err + } + + headers := []string{ + "From: " + sub.From, + "To: " + recipient, + "Subject: " + sub.Subject, + "Message-ID: " + messageID, + "Date: " + jitteredDate(), + "Newsgroups: " + strings.Join(sub.Newsgroups, ","), + "X-Ed25519-Pub: " + sub.PublicKeyB64, + "X-Ed25519-Sig: " + sub.SignatureB64, + } + + face := faceHeader(sub, identiconsCLI) + if face == "" && requireFace { + return "", "", fmt.Errorf("face header generation failed") + } + if face != "" { + headers = append(headers, "Face: "+face) + } + + if refs := normalizeReferences(sub.References); refs != "" { + headers = append(headers, "References: "+refs, "In-Reply-To: "+refs) + } + + headers = append(headers, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "Content-Transfer-Encoding: 8bit", + "User-Agent: n2usenet-https-nym/0.1", + "X-No-Archive: Yes", + ) + + return strings.Join(headers, "\r\n") + "\r\n\r\n" + sub.Message, messageID, nil +} + +func newMessageID(domain string) (string, error) { + domain = cleanHeader(domain) + if domain == "" { + domain = "n2usenet.local" + } + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return fmt.Sprintf("<%s.%d@%s>", hex.EncodeToString(b[:]), time.Now().Unix(), domain), nil +} + +func jitteredDate() string { + var b [2]byte + if _, err := rand.Read(b[:]); err != nil { + return time.Now().UTC().Format(time.RFC1123Z) + } + seconds := int(b[0])<<8 | int(b[1]) + seconds = seconds%3601 - 1800 + return time.Now().UTC().Add(time.Duration(seconds) * time.Second).Format(time.RFC1123Z) +} + +func normalizeReferences(refs string) string { + refs = cleanHeader(refs) + if refs == "" { + return "" + } + if !strings.HasPrefix(refs, "<") { + refs = "<" + refs + } + if !strings.HasSuffix(refs, ">") { + refs += ">" + } + if !messageIDRE.MatchString(refs) { + return "" + } + return refs +} + +type FaceIdentity struct { + Hash string `json:"hash"` + Face string `json:"face"` + Preview string `json:"preview"` +} + +func GenerateFace(username, email, pubkeyB64, identiconsCLI string, size int) (FaceIdentity, error) { + if identiconsCLI == "" { + return FaceIdentity{}, fmt.Errorf("identicons cli not configured") + } + if st, err := os.Stat(identiconsCLI); err != nil || st.IsDir() || st.Mode()&0111 == 0 { + return FaceIdentity{}, fmt.Errorf("identicons cli not executable") + } + username = cleanHeader(username) + email = cleanHeader(email) + pubkeyB64 = strings.TrimSpace(pubkeyB64) + if username == "" || email == "" || strings.ContainsAny(username, "|") || strings.ContainsAny(email, "|") { + return FaceIdentity{}, fmt.Errorf("invalid identity fields") + } + if _, err := base64.StdEncoding.DecodeString(pubkeyB64); err != nil { + return FaceIdentity{}, fmt.Errorf("invalid public key") + } + if size <= 0 { + size = 48 + } + input := username + "|" + email + "|" + pubkeyB64 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, identiconsCLI, "-input", input, "-size", fmt.Sprintf("%d", size), "-transparent", "-format", "base64").Output() + if err != nil { + return FaceIdentity{}, fmt.Errorf("identicons cli failed: %w", err) + } + face := strings.TrimSpace(string(out)) + if _, err := base64.StdEncoding.DecodeString(face); err != nil { + return FaceIdentity{}, fmt.Errorf("invalid face output") + } + hash := sha256.Sum256([]byte(input)) + return FaceIdentity{ + Hash: hex.EncodeToString(hash[:]), + Face: face, + Preview: "data:image/png;base64," + face, + }, nil +} + +func faceHeader(sub Submission, identiconsCLI string) string { + addr, err := mail.ParseAddress(sub.From) + if err != nil { + return "" + } + face, err := GenerateFace(addr.Name, addr.Address, sub.PublicKeyB64, identiconsCLI, 48) + if err != nil { + return "" + } + return face.Face +} -- cgit v1.2.3