summaryrefslogtreecommitdiffstats
path: root/internal/submit
diff options
context:
space:
mode:
Diffstat (limited to 'internal/submit')
-rw-r--r--internal/submit/message.go153
-rw-r--r--internal/submit/types.go358
-rw-r--r--internal/submit/types_test.go78
-rw-r--r--internal/submit/validation.go194
-rw-r--r--internal/submit/validation_test.go111
5 files changed, 894 insertions, 0 deletions
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
+}
diff --git a/internal/submit/types.go b/internal/submit/types.go
new file mode 100644
index 0000000..52f57b4
--- /dev/null
+++ b/internal/submit/types.go
@@ -0,0 +1,358 @@
+package submit
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "mime"
+ "net"
+ "net/http"
+ "path"
+ "strings"
+ "sync"
+ "time"
+
+ "n2usenet/internal/config"
+ "n2usenet/internal/smtpclient"
+ "n2usenet/internal/storage"
+)
+
+type Mailer interface {
+ Send(ctx context.Context, msg smtpclient.Message) error
+}
+
+type App struct {
+ cfg config.Config
+ mailer Mailer
+ replay *storage.ReplayCache
+ staticFS fs.FS
+ indexTmpl *template.Template
+ csrfKey []byte
+ rateKey []byte
+ rateMu sync.Mutex
+ rates map[string]rateBucket
+ locksMu sync.Mutex
+ locks map[string]struct{}
+}
+
+type rateBucket struct {
+ Count int
+ Start time.Time
+}
+
+type IndexData struct {
+ CSRFToken string
+ PublicBaseURL string
+}
+
+func NewApp(cfg config.Config, mailer Mailer, replay *storage.ReplayCache, staticFS fs.FS, indexTemplate string) (*App, error) {
+ csrfKey := make([]byte, 32)
+ rateKey := make([]byte, 32)
+ if _, err := rand.Read(csrfKey); err != nil {
+ return nil, fmt.Errorf("csrf key: %w", err)
+ }
+ if _, err := rand.Read(rateKey); err != nil {
+ return nil, fmt.Errorf("rate key: %w", err)
+ }
+ tmpl, err := template.New("index").Parse(indexTemplate)
+ if err != nil {
+ return nil, fmt.Errorf("parse index template: %w", err)
+ }
+ return &App{
+ cfg: cfg, mailer: mailer, replay: replay, staticFS: staticFS,
+ indexTmpl: tmpl, csrfKey: csrfKey, rateKey: rateKey,
+ rates: map[string]rateBucket{}, locks: map[string]struct{}{},
+ }, nil
+}
+
+func (a *App) Routes() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", a.handleIndex)
+ mux.HandleFunc("GET /healthz", a.handleHealth)
+ mux.HandleFunc("GET /favicon.ico", a.handleFavicon)
+ mux.HandleFunc("POST /identity/face", a.handleFace)
+ mux.HandleFunc("POST /identicon.php", a.handleIdenticonCompat)
+ mux.HandleFunc("POST /submit", a.handleSubmit)
+ mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(a.staticFS))))
+ return securityHeaders(mux)
+}
+
+func (a *App) handleFavicon(w http.ResponseWriter, r *http.Request) {
+ icon, err := fs.ReadFile(a.staticFS, "Nym.ico")
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "image/x-icon")
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ _, _ = w.Write(icon)
+}
+
+func (a *App) handleFace(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 8192)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+ if !a.verifyCSRF(r) {
+ http.Error(w, "invalid request token", http.StatusBadRequest)
+ return
+ }
+ face, err := GenerateFace(r.PostForm.Get("username"), r.PostForm.Get("email"), r.PostForm.Get("pubkey"), a.cfg.Security.IdenticonsCLI, 96)
+ if err != nil {
+ http.Error(w, "face generation failed", http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(face)
+}
+
+func (a *App) handleIdenticonCompat(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 8192)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+ username := r.PostForm.Get("username")
+ email := r.PostForm.Get("email")
+ pubkey := r.PostForm.Get("pubkey")
+
+ face48, err := GenerateFace(username, email, pubkey, a.cfg.Security.IdenticonsCLI, 48)
+ if err != nil {
+ http.Error(w, "face generation failed", http.StatusBadRequest)
+ return
+ }
+ preview, err := GenerateFace(username, email, pubkey, a.cfg.Security.IdenticonsCLI, 256)
+ if err != nil {
+ http.Error(w, "face generation failed", http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "hash": face48.Hash,
+ "face48": face48.Face,
+ "preview": preview.Preview,
+ "faceHeader": "Face: " + face48.Face,
+ })
+}
+
+func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
+ token := randomHex(32)
+ http.SetCookie(w, &http.Cookie{
+ Name: "m2u_csrf",
+ Value: a.signCSRF(token),
+ Path: "/",
+ MaxAge: 7200,
+ Secure: a.cfg.Security.SecureCookies,
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ })
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = a.indexTmpl.Execute(w, IndexData{CSRFToken: token, PublicBaseURL: a.cfg.PublicBaseURL})
+}
+
+func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok\n"))
+}
+
+func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) {
+ if !a.allowRate(r) {
+ a.errorResponse(w, r, http.StatusTooManyRequests, "Too many requests. Try again later.")
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, int64(a.cfg.Security.MaxMessageBytes+16384))
+ if err := r.ParseForm(); err != nil {
+ a.errorResponse(w, r, http.StatusBadRequest, "Invalid request.")
+ return
+ }
+ if !a.verifyCSRF(r) {
+ a.errorResponse(w, r, http.StatusBadRequest, "Invalid request token.")
+ return
+ }
+
+ sub, err := ParseAndValidate(r.PostForm, a.cfg.Security)
+ if err != nil {
+ a.errorResponse(w, r, http.StatusBadRequest, "Validation failed.")
+ return
+ }
+
+ locked := a.acquire(sub.TokenHash)
+ if !locked {
+ a.errorResponse(w, r, http.StatusConflict, "Duplicate submission detected.")
+ return
+ }
+ defer a.release(sub.TokenHash)
+
+ replayed, err := a.replay.CheckAndMark(sub.TokenHash)
+ if err != nil {
+ a.errorResponse(w, r, http.StatusInternalServerError, "Internal error.")
+ return
+ }
+ if replayed {
+ a.errorResponse(w, r, http.StatusConflict, "Token already used.")
+ return
+ }
+
+ raw, messageID, err := BuildMessage(sub, a.cfg.SMTP.Recipient, a.cfg.Security.MessageIDDomain, a.cfg.Security.IdenticonsCLI, a.cfg.Security.RequireFace)
+ if err != nil {
+ a.errorResponse(w, r, http.StatusInternalServerError, "Message build failed.")
+ return
+ }
+ if err := a.mailer.Send(r.Context(), smtpclient.Message{EnvelopeFrom: sub.FromAddress, Raw: raw}); err != nil {
+ a.errorResponse(w, r, http.StatusBadGateway, "Delivery failed.")
+ return
+ }
+ a.successResponse(w, r, messageID)
+}
+
+func (a *App) verifyCSRF(r *http.Request) bool {
+ formToken := strings.TrimSpace(r.PostForm.Get("csrf_token"))
+ cookie, err := r.Cookie("m2u_csrf")
+ if err != nil || formToken == "" {
+ return false
+ }
+ return hmac.Equal([]byte(cookie.Value), []byte(a.signCSRF(formToken)))
+}
+
+func (a *App) signCSRF(token string) string {
+ mac := hmac.New(sha256.New, a.csrfKey)
+ mac.Write([]byte(token))
+ return token + "." + hex.EncodeToString(mac.Sum(nil))
+}
+
+func (a *App) allowRate(r *http.Request) bool {
+ key := a.clientKey(r)
+ now := time.Now()
+ a.rateMu.Lock()
+ defer a.rateMu.Unlock()
+ for k, b := range a.rates {
+ if now.Sub(b.Start) > a.cfg.Security.RateLimitWindow {
+ delete(a.rates, k)
+ }
+ }
+ b := a.rates[key]
+ if b.Start.IsZero() || now.Sub(b.Start) > a.cfg.Security.RateLimitWindow {
+ a.rates[key] = rateBucket{Count: 1, Start: now}
+ return true
+ }
+ b.Count++
+ a.rates[key] = b
+ return b.Count <= a.cfg.Security.RateLimitCount
+}
+
+func (a *App) clientKey(r *http.Request) string {
+ ip := ""
+ if a.cfg.Security.TrustProxy {
+ ip = strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0])
+ if ip == "" {
+ ip = strings.TrimSpace(r.Header.Get("CF-Connecting-IP"))
+ }
+ }
+ if ip == "" {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err == nil {
+ ip = host
+ } else {
+ ip = r.RemoteAddr
+ }
+ }
+ mac := hmac.New(sha256.New, a.rateKey)
+ mac.Write([]byte(ip))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+func (a *App) acquire(key string) bool {
+ a.locksMu.Lock()
+ defer a.locksMu.Unlock()
+ if _, exists := a.locks[key]; exists {
+ return false
+ }
+ a.locks[key] = struct{}{}
+ return true
+}
+
+func (a *App) release(key string) {
+ a.locksMu.Lock()
+ delete(a.locks, key)
+ a.locksMu.Unlock()
+}
+
+func wantsJSON(r *http.Request) bool {
+ return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/json")
+}
+
+func setNoStoreHeaders(w http.ResponseWriter) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Pragma", "no-cache")
+ w.Header().Set("Expires", "0")
+}
+
+func (a *App) errorResponse(w http.ResponseWriter, r *http.Request, status int, msg string) {
+ setNoStoreHeaders(w)
+ if wantsJSON(r) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
+ return
+ }
+ a.errorHTML(w, status, msg)
+}
+
+func (a *App) errorHTML(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = fmt.Fprintf(w, "<!doctype html><meta charset=utf-8><title>Request failed</title><main><h1>Request failed</h1><p>%s</p><p><a href=\"/\">Back</a></p></main>", template.HTMLEscapeString(msg))
+}
+
+func (a *App) successResponse(w http.ResponseWriter, r *http.Request, messageID string) {
+ setNoStoreHeaders(w)
+ w.Header().Set("Clear-Site-Data", "\"storage\"")
+ if wantsJSON(r) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(map[string]string{"messageId": messageID})
+ return
+ }
+ a.successHTML(w, messageID)
+}
+
+func (a *App) successHTML(w http.ResponseWriter, messageID string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = fmt.Fprintf(w, "<!doctype html><meta charset=utf-8><title>Message sent</title><main><h1>Message sent</h1><p><strong>Message-ID:</strong></p><pre>%s</pre><p><a href=\"/\">Send another</a></p></main>", template.HTMLEscapeString(messageID))
+}
+
+func randomHex(n int) string {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return hex.EncodeToString(b)
+}
+
+func securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
+ ext := path.Ext(r.URL.Path)
+ if ext != "" {
+ if ctype := mime.TypeByExtension(ext); ctype != "" {
+ w.Header().Set("Content-Type", ctype)
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/submit/types_test.go b/internal/submit/types_test.go
new file mode 100644
index 0000000..a2a7c1a
--- /dev/null
+++ b/internal/submit/types_test.go
@@ -0,0 +1,78 @@
+package submit
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "testing/fstest"
+ "time"
+
+ "n2usenet/internal/config"
+ "n2usenet/internal/storage"
+)
+
+func newTestApp(t *testing.T) *App {
+ t.Helper()
+ app, err := NewApp(config.Config{}, nil, storage.NewReplayCache(time.Minute), fstest.MapFS{}, "<!doctype html>")
+ if err != nil {
+ t.Fatalf("NewApp returned error: %v", err)
+ }
+ return app
+}
+
+func TestSuccessResponseJSONNoStore(t *testing.T) {
+ app := newTestApp(t)
+ req := httptest.NewRequest(http.MethodPost, "/submit", nil)
+ req.Header.Set("Accept", "application/json")
+ rec := httptest.NewRecorder()
+
+ app.successResponse(rec, req, "<test@example.net>")
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unexpected status: got %d want %d", rec.Code, http.StatusOK)
+ }
+ if got := rec.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatalf("unexpected Cache-Control: %q", got)
+ }
+ if got := rec.Header().Get("Clear-Site-Data"); got != "\"storage\"" {
+ t.Fatalf("unexpected Clear-Site-Data: %q", got)
+ }
+ if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
+ t.Fatalf("unexpected Content-Type: %q", got)
+ }
+ var body map[string]string
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("failed to decode response body: %v", err)
+ }
+ if body["messageId"] != "<test@example.net>" {
+ t.Fatalf("unexpected messageId: %q", body["messageId"])
+ }
+}
+
+func TestErrorResponseJSONNoStore(t *testing.T) {
+ app := newTestApp(t)
+ req := httptest.NewRequest(http.MethodPost, "/submit", nil)
+ req.Header.Set("Accept", "application/json")
+ rec := httptest.NewRecorder()
+
+ app.errorResponse(rec, req, http.StatusBadRequest, "Validation failed.")
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("unexpected status: got %d want %d", rec.Code, http.StatusBadRequest)
+ }
+ if got := rec.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatalf("unexpected Cache-Control: %q", got)
+ }
+ if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
+ t.Fatalf("unexpected Content-Type: %q", got)
+ }
+ var body map[string]string
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("failed to decode response body: %v", err)
+ }
+ if body["error"] != "Validation failed." {
+ t.Fatalf("unexpected error body: %q", body["error"])
+ }
+}
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
+}
diff --git a/internal/submit/validation_test.go b/internal/submit/validation_test.go
new file mode 100644
index 0000000..630b647
--- /dev/null
+++ b/internal/submit/validation_test.go
@@ -0,0 +1,111 @@
+package submit
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha1"
+ "encoding/base64"
+ "fmt"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "n2usenet/internal/config"
+)
+
+func TestParseAndValidateSignedSubmission(t *testing.T) {
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := "This is a valid signed test message."
+ sig := ed25519.Sign(priv, []byte(body))
+ sigB64 := base64.StdEncoding.EncodeToString(sig)
+ pubB64 := base64.StdEncoding.EncodeToString(pub)
+
+ form := url.Values{}
+ form.Set("from", "Tester <tester@example.net>")
+ form.Set("newsgroups", "alt.test")
+ form.Set("subject", "Test")
+ form.Set("xhashcash", mineTestHashcash(t, "tester@example.net", 8))
+ form.Set("message", body+"\n\n--- Digital Signature ---\n"+sigB64)
+ form.Set("x-ed25519-pub", pubB64)
+ form.Set("x-ed25519-sig", sigB64)
+
+ sub, err := ParseAndValidate(form, config.SecurityConfig{
+ MinHashcashBits: 8,
+ MinMessageBytes: 10,
+ MaxMessageBytes: 1024,
+ MaxNewsgroups: 3,
+ })
+ if err != nil {
+ t.Fatalf("ParseAndValidate returned error: %v", err)
+ }
+ if sub.SignedText != body {
+ t.Fatalf("signed payload mismatch: %q", sub.SignedText)
+ }
+}
+
+func TestParseAndValidateRejectsBadSignature(t *testing.T) {
+ pub, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, otherPriv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := "This is a valid sized test message."
+ sig := ed25519.Sign(otherPriv, []byte("different"))
+ form := url.Values{}
+ form.Set("from", "Tester <tester@example.net>")
+ form.Set("newsgroups", "alt.test")
+ form.Set("subject", "Test")
+ form.Set("xhashcash", mineTestHashcash(t, "tester@example.net", 8))
+ form.Set("message", body+"\n\n--- Digital Signature ---\n"+base64.StdEncoding.EncodeToString(sig))
+ form.Set("x-ed25519-pub", base64.StdEncoding.EncodeToString(pub))
+ form.Set("x-ed25519-sig", base64.StdEncoding.EncodeToString(sig))
+
+ _, err = ParseAndValidate(form, config.SecurityConfig{
+ MinHashcashBits: 8,
+ MinMessageBytes: 10,
+ MaxMessageBytes: 1024,
+ MaxNewsgroups: 3,
+ })
+ if err == nil {
+ t.Fatal("expected bad signature to be rejected")
+ }
+}
+
+func TestBuildMessageRequiresFaceWhenConfigured(t *testing.T) {
+ sub := Submission{
+ From: "Tester <tester@example.net>",
+ FromAddress: "tester@example.net",
+ Newsgroups: []string{"alt.test"},
+ Subject: "Test",
+ Message: "This is a body.",
+ PublicKeyB64: base64.StdEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize)),
+ SignatureB64: base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)),
+ }
+ _, _, err := BuildMessage(sub, "mail2news@mail2news.tcpreset.net", "example.net", "/missing/identicons-cli", true)
+ if err == nil {
+ t.Fatal("expected missing Face generator to reject message")
+ }
+}
+
+func mineTestHashcash(t *testing.T, resource string, bits int) string {
+ t.Helper()
+ date := time.Now().UTC().Format("060102150405")
+ prefix := fmt.Sprintf("1:%d:%s:%s::test:", bits, date, resource)
+ target := bits / 4
+ for i := 0; i < 1_000_000; i++ {
+ token := fmt.Sprintf("%s%d", prefix, i)
+ sum := sha1.Sum([]byte(token))
+ if strings.HasPrefix(fmt.Sprintf("%x", sum[:]), strings.Repeat("0", target)) {
+ return token
+ }
+ }
+ t.Fatal("failed to mine test hashcash")
+ return ""
+}