diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-08-03 17:13:50 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-08-03 17:13:50 +0200 |
| commit | cd58d593789c6facb1d05b7f255bc2c5f2e46010 (patch) | |
| tree | 87a226313e49e2c6f7b7028d77d4957db416d1c1 /internal/submit | |
| download | n2usenet-cd58d593789c6facb1d05b7f255bc2c5f2e46010.tar.gz n2usenet-cd58d593789c6facb1d05b7f255bc2c5f2e46010.tar.xz n2usenet-cd58d593789c6facb1d05b7f255bc2c5f2e46010.zip | |
Initial import: n2usenet HTTPS/Nym Usenet gateway
Diffstat (limited to 'internal/submit')
| -rw-r--r-- | internal/submit/message.go | 153 | ||||
| -rw-r--r-- | internal/submit/types.go | 358 | ||||
| -rw-r--r-- | internal/submit/types_test.go | 78 | ||||
| -rw-r--r-- | internal/submit/validation.go | 194 | ||||
| -rw-r--r-- | internal/submit/validation_test.go | 111 |
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 "" +} |
