summaryrefslogtreecommitdiffstats
path: root/internal/submit/validation.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/submit/validation.go')
-rw-r--r--internal/submit/validation.go194
1 files changed, 194 insertions, 0 deletions
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<<uint(i)) == 0 {
+ total++
+ continue
+ }
+ return total
+ }
+ }
+ return total
+}
+
+func SignedPayload(message, sigB64 string) string {
+ message = strings.TrimSpace(message)
+ marker := "\n\n--- Digital Signature ---\n" + strings.TrimSpace(sigB64)
+ if strings.HasSuffix(message, marker) {
+ return strings.TrimSpace(strings.TrimSuffix(message, marker))
+ }
+ return message
+}
+
+func VerifySignature(message, pubB64, sigB64 string) error {
+ pub, err := base64.StdEncoding.DecodeString(pubB64)
+ if err != nil || len(pub) != ed25519.PublicKeySize {
+ return fmt.Errorf("invalid public key")
+ }
+ sig, err := base64.StdEncoding.DecodeString(sigB64)
+ if err != nil || len(sig) != ed25519.SignatureSize {
+ return fmt.Errorf("invalid signature")
+ }
+ if !ed25519.Verify(ed25519.PublicKey(pub), []byte(message), sig) {
+ return fmt.Errorf("signature verification failed")
+ }
+ return nil
+}