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<