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/validation.go | 194 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 internal/submit/validation.go (limited to 'internal/submit/validation.go') diff --git a/internal/submit/validation.go b/internal/submit/validation.go new file mode 100644 index 0000000..347961d --- /dev/null +++ b/internal/submit/validation.go @@ -0,0 +1,194 @@ +package submit + +import ( + "crypto/ed25519" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/mail" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "n2usenet/internal/config" +) + +var newsgroupRE = regexp.MustCompile(`(?i)^[a-z0-9][a-z0-9.-]*[a-z0-9]$`) + +type Submission struct { + From string + FromAddress string + Newsgroups []string + Subject string + References string + Hashcash string + Message string + SignedText string + PublicKeyB64 string + SignatureB64 string + TokenHash string +} + +func ParseAndValidate(form url.Values, cfg config.SecurityConfig) (Submission, error) { + sub := Submission{ + From: strings.TrimSpace(form.Get("from")), + Subject: cleanHeader(form.Get("subject")), + References: cleanHeader(form.Get("references")), + Hashcash: normalizeHashcash(form.Get("xhashcash")), + Message: strings.TrimSpace(form.Get("message")), + PublicKeyB64: strings.TrimSpace(form.Get("x-ed25519-pub")), + SignatureB64: strings.TrimSpace(form.Get("x-ed25519-sig")), + } + if sub.From == "" || sub.Subject == "" || sub.Hashcash == "" || sub.Message == "" { + return Submission{}, fmt.Errorf("missing required field") + } + if len(sub.Subject) > 200 { + return Submission{}, fmt.Errorf("subject too long") + } + if len([]byte(sub.Message)) < cfg.MinMessageBytes || len([]byte(sub.Message)) > cfg.MaxMessageBytes { + return Submission{}, fmt.Errorf("invalid message size") + } + addr, err := mail.ParseAddress(sub.From) + if err != nil || addr.Address == "" || addr.Name == "" { + return Submission{}, fmt.Errorf("invalid from") + } + sub.FromAddress = addr.Address + + groups := splitNewsgroups(form.Get("newsgroups")) + if len(groups) == 0 || len(groups) > cfg.MaxNewsgroups { + return Submission{}, fmt.Errorf("invalid newsgroup count") + } + for _, group := range groups { + if !newsgroupRE.MatchString(group) { + return Submission{}, fmt.Errorf("invalid newsgroup") + } + } + sub.Newsgroups = groups + + if err := VerifyHashcash(sub.Hashcash, sub.FromAddress, cfg.MinHashcashBits, 48*time.Hour, 2*time.Hour); err != nil { + return Submission{}, err + } + h := sha256.Sum256([]byte(sub.Hashcash)) + sub.TokenHash = hex.EncodeToString(h[:]) + + if sub.PublicKeyB64 == "" || sub.SignatureB64 == "" { + return Submission{}, fmt.Errorf("missing signature") + } + signedText := SignedPayload(sub.Message, sub.SignatureB64) + if err := VerifySignature(signedText, sub.PublicKeyB64, sub.SignatureB64); err != nil { + return Submission{}, err + } + sub.SignedText = signedText + return sub, nil +} + +func splitNewsgroups(raw string) []string { + var out []string + for _, part := range strings.Split(raw, ",") { + part = strings.ToLower(strings.TrimSpace(part)) + if part != "" { + out = append(out, part) + } + } + return out +} + +func cleanHeader(v string) string { + v = strings.TrimSpace(v) + v = strings.ReplaceAll(v, "\r", "") + v = strings.ReplaceAll(v, "\n", "") + return v +} + +func normalizeHashcash(token string) string { + return strings.Join(strings.Fields(strings.TrimSpace(token)), "") +} + +func VerifyHashcash(token, resource string, minBits int, maxAge, maxFuture time.Duration) error { + parts := strings.Split(token, ":") + if len(parts) != 7 { + return fmt.Errorf("invalid hashcash format") + } + if parts[0] != "1" { + return fmt.Errorf("unsupported hashcash version") + } + bits, err := strconv.Atoi(parts[1]) + if err != nil || bits < minBits { + return fmt.Errorf("insufficient hashcash bits") + } + if !strings.EqualFold(strings.TrimSpace(parts[3]), strings.TrimSpace(resource)) { + return fmt.Errorf("hashcash resource mismatch") + } + ts, err := parseHashcashTime(parts[2]) + if err != nil { + return fmt.Errorf("invalid hashcash date") + } + now := time.Now().UTC() + if now.Sub(ts) > maxAge || ts.Sub(now) > maxFuture { + return fmt.Errorf("hashcash date outside allowed window") + } + sum := sha1.Sum([]byte(token)) + if leadingZeroBits(sum[:]) < bits { + return fmt.Errorf("hashcash proof invalid") + } + return nil +} + +func parseHashcashTime(v string) (time.Time, error) { + layouts := []string{"060102150405", "0601021504", "06010215", "060102"} + for _, layout := range layouts { + if len(v) != len(layout) { + continue + } + if t, err := time.ParseInLocation(layout, v, time.UTC); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("unsupported date") +} + +func leadingZeroBits(b []byte) int { + total := 0 + for _, x := range b { + if x == 0 { + total += 8 + continue + } + for i := 7; i >= 0; i-- { + if x&(1<