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/assets/assets.go | 6 + internal/assets/web/static/Nym.ico | Bin 0 -> 1406 bytes internal/assets/web/static/nacl-util.min.js | 1 + internal/assets/web/static/nacl.min.js | 1 + internal/assets/web/static/powWorker.js | 57 + internal/assets/web/static/style.css | 150 +++ internal/assets/web/templates/index.html | 1577 +++++++++++++++++++++++++++ internal/config/config.go | 209 ++++ internal/nymclient/manager.go | 176 +++ internal/smtpclient/client.go | 208 ++++ internal/smtpclient/client_test.go | 93 ++ internal/socks5/dialer.go | 131 +++ internal/storage/replay.go | 32 + internal/storage/replay_test.go | 36 + internal/submit/message.go | 153 +++ internal/submit/types.go | 358 ++++++ internal/submit/types_test.go | 78 ++ internal/submit/validation.go | 194 ++++ internal/submit/validation_test.go | 111 ++ 19 files changed, 3571 insertions(+) create mode 100644 internal/assets/assets.go create mode 100644 internal/assets/web/static/Nym.ico create mode 100644 internal/assets/web/static/nacl-util.min.js create mode 100644 internal/assets/web/static/nacl.min.js create mode 100644 internal/assets/web/static/powWorker.js create mode 100644 internal/assets/web/static/style.css create mode 100644 internal/assets/web/templates/index.html create mode 100644 internal/config/config.go create mode 100644 internal/nymclient/manager.go create mode 100644 internal/smtpclient/client.go create mode 100644 internal/smtpclient/client_test.go create mode 100644 internal/socks5/dialer.go create mode 100644 internal/storage/replay.go create mode 100644 internal/storage/replay_test.go create mode 100644 internal/submit/message.go create mode 100644 internal/submit/types.go create mode 100644 internal/submit/types_test.go create mode 100644 internal/submit/validation.go create mode 100644 internal/submit/validation_test.go (limited to 'internal') diff --git a/internal/assets/assets.go b/internal/assets/assets.go new file mode 100644 index 0000000..827f15c --- /dev/null +++ b/internal/assets/assets.go @@ -0,0 +1,6 @@ +package assets + +import "embed" + +//go:embed web/templates/index.html web/static/* +var FS embed.FS diff --git a/internal/assets/web/static/Nym.ico b/internal/assets/web/static/Nym.ico new file mode 100644 index 0000000..26a2943 Binary files /dev/null and b/internal/assets/web/static/Nym.ico differ diff --git a/internal/assets/web/static/nacl-util.min.js b/internal/assets/web/static/nacl-util.min.js new file mode 100644 index 0000000..0426742 --- /dev/null +++ b/internal/assets/web/static/nacl-util.min.js @@ -0,0 +1 @@ +!function(e,n){"use strict";"undefined"!=typeof module&&module.exports?module.exports=n():(e.nacl||(e.nacl={}),e.nacl.util=n())}(this,function(){"use strict";var e={};function o(e){if(!/^(?:[A-Za-z0-9+\/]{2}[A-Za-z0-9+\/]{2})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/.test(e))throw new TypeError("invalid encoding")}return e.decodeUTF8=function(e){if("string"!=typeof e)throw new TypeError("expected string");var n,r=unescape(encodeURIComponent(e)),t=new Uint8Array(r.length);for(n=0;n>>32-n}function b(r,n){var e=255&r[n+3];return(e=(e=e<<8|255&r[n+2])<<8|255&r[n+1])<<8|255&r[n+0]}function B(r,n){var e=r[n]<<24|r[n+1]<<16|r[n+2]<<8|r[n+3],t=r[n+4]<<24|r[n+5]<<16|r[n+6]<<8|r[n+7];return new m(e,t)}function p(r,n,e){var t;for(t=0;t<4;t++)r[n+t]=255&e,e>>>=8}function S(r,n,e){r[n]=e.hi>>24&255,r[n+1]=e.hi>>16&255,r[n+2]=e.hi>>8&255,r[n+3]=255&e.hi,r[n+4]=e.lo>>24&255,r[n+5]=e.lo>>16&255,r[n+6]=e.lo>>8&255,r[n+7]=255&e.lo}function u(r,n,e,t,o){var i,a=0;for(i=0;i>>8)-1}function A(r,n,e,t){return u(r,n,e,t,16)}function _(r,n,e,t){return u(r,n,e,t,32)}function U(r,n,e,t,o){var i,a,f,u=new Uint32Array(16),c=new Uint32Array(16),w=new Uint32Array(16),y=new Uint32Array(4);for(i=0;i<4;i++)c[5*i]=b(t,4*i),c[1+i]=b(e,4*i),c[6+i]=b(n,4*i),c[11+i]=b(e,16+4*i);for(i=0;i<16;i++)w[i]=c[i];for(i=0;i<20;i++){for(a=0;a<4;a++){for(f=0;f<4;f++)y[f]=c[(5*a+4*f)%16];for(y[1]^=h(y[0]+y[3]|0,7),y[2]^=h(y[1]+y[0]|0,9),y[3]^=h(y[2]+y[1]|0,13),y[0]^=h(y[3]+y[2]|0,18),f=0;f<4;f++)u[4*a+(a+f)%4]=y[f]}for(f=0;f<16;f++)c[f]=u[f]}if(o){for(i=0;i<16;i++)c[i]=c[i]+w[i]|0;for(i=0;i<4;i++)c[5*i]=c[5*i]-b(t,4*i)|0,c[6+i]=c[6+i]-b(n,4*i)|0;for(i=0;i<4;i++)p(r,4*i,c[5*i]),p(r,16+4*i,c[6+i])}else for(i=0;i<16;i++)p(r,4*i,c[i]+w[i]|0)}function E(r,n,e,t){U(r,n,e,t,!1)}function x(r,n,e,t){return U(r,n,e,t,!0),0}var d=new Uint8Array([101,120,112,97,110,100,32,51,50,45,98,121,116,101,32,107]);function K(r,n,e,t,o,i,a){var f,u,c=new Uint8Array(16),w=new Uint8Array(64);if(!o)return 0;for(u=0;u<16;u++)c[u]=0;for(u=0;u<8;u++)c[u]=i[u];for(;64<=o;){for(E(w,c,a,d),u=0;u<64;u++)r[n+u]=(e?e[t+u]:0)^w[u];for(f=1,u=8;u<16;u++)f=f+(255&c[u])|0,c[u]=255&f,f>>>=8;o-=64,n+=64,e&&(t+=64)}if(0>>=8}var z=new Uint32Array([5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,252]);function R(r,n,e,t,o,i){var a,f,u,c,w=new Uint32Array(17),y=new Uint32Array(17),l=new Uint32Array(17),s=new Uint32Array(17),h=new Uint32Array(17);for(u=0;u<17;u++)y[u]=l[u]=0;for(u=0;u<16;u++)y[u]=i[u];for(y[3]&=15,y[4]&=252,y[7]&=15,y[8]&=252,y[11]&=15,y[12]&=252,y[15]&=15;0>>=8;for(c=c+l[16]|0,l[16]=3&c,c=5*(c>>>2)|0,u=0;u<16;u++)c=c+l[u]|0,l[u]=255&c,c>>>=8;c=c+l[16]|0,l[16]=c}for(u=0;u<17;u++)h[u]=l[u];for(k(l,z),a=0|-(l[16]>>>7),u=0;u<17;u++)l[u]^=a&(h[u]^l[u]);for(u=0;u<16;u++)s[u]=i[u+16];for(s[16]=0,k(l,s),u=0;u<16;u++)r[n+u]=l[u];return 0}function P(r,n,e,t,o,i){var a=new Uint8Array(16);return R(a,0,e,t,o,i),A(r,n,a,0)}function M(r,n,e,t,o){var i;if(e<32)return-1;for(T(r,0,n,0,e,t,o),R(r,16,r,32,e-32,r),i=0;i<16;i++)r[i]=0;return 0}function N(r,n,e,t,o){var i,a=new Uint8Array(32);if(e<32)return-1;if(L(a,0,32,t,o),0!==P(n,16,n,32,e-32,a))return-1;for(T(r,0,n,0,e,t,o),i=0;i<32;i++)r[i]=0;return 0}function O(r,n){var e;for(e=0;e<16;e++)r[e]=0|n[e]}function C(r){var n,e;for(e=0;e<16;e++)r[e]+=65536,n=Math.floor(r[e]/65536),r[(e+1)*(e<15?1:0)]+=n-1+37*(n-1)*(15===e?1:0),r[e]-=65536*n}function F(r,n,e){for(var t,o=~(e-1),i=0;i<16;i++)t=o&(r[i]^n[i]),r[i]^=t,n[i]^=t}function Z(r,n){var e,t,o,i=v(),a=v();for(e=0;e<16;e++)a[e]=n[e];for(C(a),C(a),C(a),t=0;t<2;t++){for(i[0]=a[0]-65517,e=1;e<15;e++)i[e]=a[e]-65535-(i[e-1]>>16&1),i[e-1]&=65535;i[15]=a[15]-32767-(i[14]>>16&1),o=i[15]>>16&1,i[14]&=65535,F(a,i,1-o)}for(e=0;e<16;e++)r[2*e]=255&a[e],r[2*e+1]=a[e]>>8}function G(r,n){var e=new Uint8Array(32),t=new Uint8Array(32);return Z(e,r),Z(t,n),_(e,0,t,0)}function q(r){var n=new Uint8Array(32);return Z(n,r),1&n[0]}function D(r,n){var e;for(e=0;e<16;e++)r[e]=n[2*e]+(n[2*e+1]<<8);r[15]&=32767}function I(r,n,e){var t;for(t=0;t<16;t++)r[t]=n[t]+e[t]|0}function V(r,n,e){var t;for(t=0;t<16;t++)r[t]=n[t]-e[t]|0}function X(r,n,e){var t,o,i=new Float64Array(31);for(t=0;t<31;t++)i[t]=0;for(t=0;t<16;t++)for(o=0;o<16;o++)i[t+o]+=n[t]*e[o];for(t=0;t<15;t++)i[t]+=38*i[t+16];for(t=0;t<16;t++)r[t]=i[t];C(r),C(r)}function j(r,n){X(r,n,n)}function H(r,n){var e,t=v();for(e=0;e<16;e++)t[e]=n[e];for(e=253;0<=e;e--)j(t,t),2!==e&&4!==e&&X(t,t,n);for(e=0;e<16;e++)r[e]=t[e]}function J(r,n){var e,t=v();for(e=0;e<16;e++)t[e]=n[e];for(e=250;0<=e;e--)j(t,t),1!==e&&X(t,t,n);for(e=0;e<16;e++)r[e]=t[e]}function Q(r,n,e){var t,o,i=new Uint8Array(32),a=new Float64Array(80),f=v(),u=v(),c=v(),w=v(),y=v(),l=v();for(o=0;o<31;o++)i[o]=n[o];for(i[31]=127&n[31]|64,i[0]&=248,D(a,e),o=0;o<16;o++)u[o]=a[o],w[o]=f[o]=c[o]=0;for(f[0]=w[0]=1,o=254;0<=o;--o)F(f,u,t=i[o>>>3]>>>(7&o)&1),F(c,w,t),I(y,f,c),V(f,f,c),I(c,u,w),V(u,u,w),j(w,y),j(l,f),X(f,c,f),X(c,u,y),I(y,f,c),V(f,f,c),j(u,f),V(c,w,l),X(f,c,g),I(f,f,w),X(c,c,f),X(f,w,l),X(w,u,a),j(u,y),F(f,u,t),F(c,w,t);for(o=0;o<16;o++)a[o+16]=f[o],a[o+32]=c[o],a[o+48]=u[o],a[o+64]=w[o];var s=a.subarray(32),h=a.subarray(16);return H(s,s),X(h,h,s),Z(r,h),0}function W(r,n){return Q(r,n,e)}function $(r,n){return a(n,32),W(r,n)}function rr(r,n,e){var t=new Uint8Array(32);return Q(t,e,n),x(r,o,t,d)}var nr=M,er=N;function tr(){var r,n,e,t=0,o=0,i=0,a=0,f=65535;for(e=0;e>>16,i+=(n=arguments[e].hi)&f,a+=n>>>16;return new m((i+=(o+=t>>>16)>>>16)&f|(a+=i>>>16)<<16,t&f|o<<16)}function or(r,n){return new m(r.hi>>>n,r.lo>>>n|r.hi<<32-n)}function ir(){var r,n=0,e=0;for(r=0;r>>n|r.lo<>>n|r.hi<>>n|r.hi<>>n|r.lo<>(7&o)&1),yr(n,r),yr(r,r),lr(r,n,t)}function vr(r,n){var e=[v(),v(),v(),v()];O(e[0],t),O(e[1],f),O(e[2],w),X(e[3],t,f),hr(r,e,n)}function gr(r,n,e){var t,o=new Uint8Array(64),i=[v(),v(),v(),v()];for(e||a(n,32),wr(o,n,32),o[0]&=248,o[31]&=127,o[31]|=64,vr(i,o),sr(r,i),t=0;t<32;t++)n[t+32]=r[t];return 0}var br=new Float64Array([237,211,245,92,26,99,18,88,214,156,247,162,222,249,222,20,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,16]);function pr(r,n){var e,t,o,i;for(t=63;32<=t;--t){for(e=0,o=t-32,i=t-12;o>4)*br[o],e=n[o]>>8,n[o]&=255;for(o=0;o<32;o++)n[o]-=e*br[o];for(t=0;t<32;t++)n[t+1]+=n[t]>>8,r[t]=255&n[t]}function Ar(r){var n,e=new Float64Array(64);for(n=0;n<64;n++)e[n]=r[n];for(n=0;n<64;n++)r[n]=0;pr(r,e)}function _r(r,n,e,t){var o,i,a=new Uint8Array(64),f=new Uint8Array(64),u=new Uint8Array(64),c=new Float64Array(64),w=[v(),v(),v(),v()];wr(a,t,32),a[0]&=248,a[31]&=127,a[31]|=64;var y=e+64;for(o=0;o>7&&V(r[0],c,r[0]),X(r[3],r[0],r[1])}(u,t))return-1;for(o=0;o { + const { prefix, targetZeros, startNonce, step } = data; + const zeroStr = '0'.repeat(targetZeros); + let nonce = startNonce; + let checked = 0; + + // Batch size: controllare piΓΉ nonce per ogni aggiornamento di progresso + const BATCH_SIZE = 500; + + // Parallelizzazione del lavoro utilizzando piΓΉ promise contemporaneamente + const CONCURRENT_PROMISES = 12; + + // Funzione per verificare un singolo nonce + const checkNonce = async (nonceToCheck) => { + const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(prefix + nonceToCheck)); + const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); + return { nonce: nonceToCheck, hex }; + }; + + // Funzione per verificare un batch di nonce in parallelo + const processBatch = async () => { + const promises = []; + for (let i = 0; i < CONCURRENT_PROMISES; i++) { + let batchPromises = []; + for (let j = 0; j < BATCH_SIZE; j++) { + const currentNonce = nonce; + batchPromises.push(checkNonce(currentNonce)); + nonce += step; + } + promises.push(Promise.all(batchPromises)); + } + + // Attende il completamento di tutti i batch + const results = await Promise.all(promises); + + // Appiattisce i risultati e controlla se c'Γ¨ una corrispondenza + const allResults = results.flat(); + for (const result of allResults) { + if (result.hex.startsWith(zeroStr)) { + return { found: true, nonce: result.nonce }; + } + } + + checked += CONCURRENT_PROMISES * BATCH_SIZE; + self.postMessage({ type: 'progress', checked }); + return { found: false }; + }; + + // Loop principale + while (true) { + const { found, nonce: foundNonce } = await processBatch(); + if (found) { + self.postMessage({ type: 'found', nonce: foundNonce }); + break; + } + } +}; diff --git a/internal/assets/web/static/style.css b/internal/assets/web/static/style.css new file mode 100644 index 0000000..c016b5d --- /dev/null +++ b/internal/assets/web/static/style.css @@ -0,0 +1,150 @@ +:root { + color-scheme: light dark; + --bg: #f4f5f7; + --fg: #171a1f; + --muted: #657083; + --panel: #ffffff; + --border: #cbd3df; + --accent: #0f766e; + --accent-dark: #0b5f59; + --danger: #b42318; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #121417; + --fg: #f2f4f7; + --muted: #a5adba; + --panel: #1b1f26; + --border: #3a4351; + --accent: #2dd4bf; + --accent-dark: #14b8a6; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; +} + +.shell { + width: min(960px, calc(100vw - 32px)); + margin: 0 auto; + padding: 24px 0 40px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: 4px; font-size: 2rem; } +h2 { font-size: 1.1rem; } +p, footer { color: var(--muted); } + +.health { + color: var(--accent); + text-decoration: none; + font-size: 0.95rem; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 18px; + margin-bottom: 16px; +} + +.grid { + display: grid; + gap: 14px; +} + +.grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +label { + display: block; + color: var(--muted); + font-size: 0.9rem; + font-weight: 600; +} + +input, textarea, select { + width: 100%; + margin-top: 6px; + padding: 10px 11px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--fg); + font: inherit; +} + +textarea { + resize: vertical; + min-height: 130px; +} + +input[readonly], textarea[readonly] { + color: var(--muted); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 14px 0; +} + +button, .fileButton { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0 14px; + border: 0; + border-radius: 6px; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +button:hover, .fileButton:hover { background: var(--accent-dark); } +button:disabled { opacity: 0.5; cursor: not-allowed; } +.fileButton input { display: none; } + +.final { + justify-content: flex-end; +} + +footer { + padding-top: 8px; + font-size: 0.85rem; +} + +.errorText { + color: var(--danger); + font-weight: 700; +} + +@media (max-width: 700px) { + .grid.two { grid-template-columns: 1fr; } + .topbar { align-items: flex-start; flex-direction: column; } + .actions.final { justify-content: stretch; } + .actions.final button { width: 100%; } +} + diff --git a/internal/assets/web/templates/index.html b/internal/assets/web/templates/index.html new file mode 100644 index 0000000..0d2d820 --- /dev/null +++ b/internal/assets/web/templates/index.html @@ -0,0 +1,1577 @@ + + + + + + N2Usenet Gateway v2.7 Nym + + + + + + + +
+
+

N2Usenet Gateway v2.7 Nym

+
+ 🌞 + + + πŸŒ™ +
+
+ + + +
+ + + +
+ +
+

1. Load Identity or Start Fresh

+ +
+

πŸ”‘ Have a saved identity? Load it to auto-fill all fields.

+ + +

Or fill the fields below to create a new identity.

+
+ + + + + + + + + +
Ready
+ +
Token will appear here after generation
+
+ +
+

2. Sign Your Message

+ + + + + + + + + + + +
+ +
+ + + + + +
Public key will appear here
+ +
Signature will appear here
+
+ +
+

Send Message

+ + + + +
+ + + + + + + + + + + + + +
+
+ +
+
N2Usenet Gateway v2.7.0 Β© 2025 - Privacy-focused Usenet posting via Nym
+ +
+
+ + + + + diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5ee60ea --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,209 @@ +package config + +import ( + "fmt" + "net" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Listen string + PublicBaseURL string + + SMTP SMTPConfig + Nym NymConfig + Security SecurityConfig +} + +type SMTPConfig struct { + Host string + Port int + Recipient string + EnvelopeFrom string + HELO string + TLSServerName string + RequireTLS bool + ImplicitTLS bool + Timeout time.Duration + DryRun bool +} + +type NymConfig struct { + Enabled bool + Managed bool + Binary string + Provider string + HomeDir string + ClientID string + SocksAddr string + AnonymousReplies bool + StartupTimeout time.Duration +} + +type SecurityConfig struct { + MinHashcashBits int + MinMessageBytes int + MaxMessageBytes int + MaxNewsgroups int + RateLimitCount int + RateLimitWindow time.Duration + TrustProxy bool + SecureCookies bool + MessageIDDomain string + IdenticonsCLI string + RequireFace bool +} + +func Load() (Config, error) { + cfg := Config{ + Listen: env("N2U_LISTEN", "M2U_LISTEN", "127.0.0.1:8095"), + PublicBaseURL: env("N2U_PUBLIC_BASE_URL", "M2U_PUBLIC_BASE_URL", "https://n2usenet.virebent.art"), + SMTP: SMTPConfig{ + Host: env("N2U_SMTP_HOST", "M2U_SMTP_HOST", "mail2news.tcpreset.net"), + Port: envInt("N2U_SMTP_PORT", "M2U_SMTP_PORT", 25), + Recipient: env("N2U_SMTP_RECIPIENT", "M2U_SMTP_RECIPIENT", "mail2news@mail2news.tcpreset.net"), + EnvelopeFrom: env("N2U_SMTP_ENVELOPE_FROM", "M2U_SMTP_ENVELOPE_FROM", "n2usenet@virebent.art"), + HELO: env("N2U_SMTP_HELO", "M2U_SMTP_HELO", "n2usenet.virebent.art"), + TLSServerName: env("N2U_SMTP_TLS_SERVER_NAME", "M2U_SMTP_TLS_SERVER_NAME", "mail.tcpreset.net"), + RequireTLS: envBool("N2U_SMTP_REQUIRE_TLS", "M2U_SMTP_REQUIRE_TLS", true), + ImplicitTLS: envBool("N2U_SMTP_IMPLICIT_TLS", "M2U_SMTP_IMPLICIT_TLS", false), + Timeout: envDuration("N2U_SMTP_TIMEOUT", "M2U_SMTP_TIMEOUT", 90*time.Second), + DryRun: envBool("N2U_DRY_RUN", "M2U_DRY_RUN", false), + }, + Nym: NymConfig{ + Enabled: envBool("N2U_NYM_ENABLED", "M2U_NYM_ENABLED", true), + Managed: envBool("N2U_NYM_MANAGED", "M2U_NYM_MANAGED", false), + Binary: env("N2U_NYM_BINARY", "M2U_NYM_BINARY", "nym-socks5-client"), + Provider: env("N2U_NYM_PROVIDER", "M2U_NYM_PROVIDER", ""), + HomeDir: env("N2U_NYM_HOME", "M2U_NYM_HOME", "./data/nym"), + ClientID: env("N2U_NYM_CLIENT_ID", "M2U_NYM_CLIENT_ID", "n2usenet"), + SocksAddr: env("N2U_NYM_SOCKS", "M2U_NYM_SOCKS", "127.0.0.1:11080"), + AnonymousReplies: envBool("N2U_NYM_ANONYMOUS_REPLIES", "M2U_NYM_ANONYMOUS_REPLIES", true), + StartupTimeout: envDuration("N2U_NYM_STARTUP_TIMEOUT", "M2U_NYM_STARTUP_TIMEOUT", 120*time.Second), + }, + Security: SecurityConfig{ + MinHashcashBits: envInt("N2U_HASHCASH_MIN_BITS", "M2U_HASHCASH_MIN_BITS", 20), + MinMessageBytes: envInt("N2U_MIN_MESSAGE_BYTES", "M2U_MIN_MESSAGE_BYTES", 10), + MaxMessageBytes: envInt("N2U_MAX_MESSAGE_BYTES", "M2U_MAX_MESSAGE_BYTES", 65536), + MaxNewsgroups: envInt("N2U_MAX_NEWSGROUPS", "M2U_MAX_NEWSGROUPS", 3), + RateLimitCount: envInt("N2U_RATE_LIMIT_COUNT", "M2U_RATE_LIMIT_COUNT", 10), + RateLimitWindow: envDuration("N2U_RATE_LIMIT_WINDOW", "M2U_RATE_LIMIT_WINDOW", time.Hour), + TrustProxy: envBool("N2U_TRUST_PROXY", "M2U_TRUST_PROXY", true), + SecureCookies: envBool("N2U_SECURE_COOKIES", "M2U_SECURE_COOKIES", true), + MessageIDDomain: env("N2U_MESSAGE_ID_DOMAIN", "M2U_MESSAGE_ID_DOMAIN", "n2usenet.virebent.art"), + IdenticonsCLI: env("N2U_IDENTICONS_CLI", "M2U_IDENTICONS_CLI", "/usr/local/bin/identicons-cli"), + RequireFace: envBool("N2U_REQUIRE_FACE", "M2U_REQUIRE_FACE", true), + }, + } + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func (c Config) Validate() error { + if _, _, err := net.SplitHostPort(c.Listen); err != nil { + return fmt.Errorf("invalid N2U_LISTEN: %w", err) + } + if c.SMTP.Host == "" { + return fmt.Errorf("N2U_SMTP_HOST is required") + } + if c.SMTP.Port < 1 || c.SMTP.Port > 65535 { + return fmt.Errorf("invalid N2U_SMTP_PORT") + } + if c.SMTP.Recipient == "" { + return fmt.Errorf("N2U_SMTP_RECIPIENT is required") + } + if c.SMTP.EnvelopeFrom == "" { + return fmt.Errorf("N2U_SMTP_ENVELOPE_FROM is required") + } + if c.SMTP.RequireTLS && c.SMTP.TLSServerName == "" { + return fmt.Errorf("N2U_SMTP_TLS_SERVER_NAME is required when TLS is required") + } + if c.Nym.Enabled { + if _, _, err := net.SplitHostPort(c.Nym.SocksAddr); err != nil { + return fmt.Errorf("invalid N2U_NYM_SOCKS: %w", err) + } + if c.Nym.Managed && c.Nym.Provider == "" { + return fmt.Errorf("N2U_NYM_PROVIDER is required when N2U_NYM_MANAGED=true") + } + } + if c.Security.MinHashcashBits < 1 || c.Security.MinHashcashBits > 32 { + return fmt.Errorf("invalid N2U_HASHCASH_MIN_BITS") + } + if c.Security.MinMessageBytes < 0 || c.Security.MaxMessageBytes <= c.Security.MinMessageBytes { + return fmt.Errorf("invalid message size limits") + } + if c.Security.MaxNewsgroups < 1 || c.Security.MaxNewsgroups > 10 { + return fmt.Errorf("invalid N2U_MAX_NEWSGROUPS") + } + if c.Security.RateLimitCount < 1 { + return fmt.Errorf("invalid N2U_RATE_LIMIT_COUNT") + } + if c.Security.RequireFace && c.Security.IdenticonsCLI == "" { + return fmt.Errorf("N2U_IDENTICONS_CLI is required when N2U_REQUIRE_FACE=true") + } + return nil +} + +func env(primary, legacy, fallback string) string { + v, ok := envValue(primary, legacy) + if !ok { + return fallback + } + return v +} + +func envBool(primary, legacy string, fallback bool) bool { + v, ok := envValue(primary, legacy) + if !ok { + return fallback + } + switch strings.ToLower(v) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +} + +func envInt(primary, legacy string, fallback int) int { + v, ok := envValue(primary, legacy) + if !ok { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} + +func envDuration(primary, legacy string, fallback time.Duration) time.Duration { + v, ok := envValue(primary, legacy) + if !ok { + return fallback + } + if d, err := time.ParseDuration(v); err == nil { + return d + } + if n, err := strconv.Atoi(v); err == nil { + return time.Duration(n) * time.Second + } + return fallback +} + +func envValue(primary, legacy string) (string, bool) { + for _, key := range []string{primary, legacy} { + v := strings.TrimSpace(os.Getenv(key)) + if v != "" { + return v, true + } + } + return "", false +} diff --git a/internal/nymclient/manager.go b/internal/nymclient/manager.go new file mode 100644 index 0000000..3c0b15d --- /dev/null +++ b/internal/nymclient/manager.go @@ -0,0 +1,176 @@ +package nymclient + +import ( + "context" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +type Config struct { + Binary string + Provider string + HomeDir string + ClientID string + SocksAddr string + AnonymousReplies bool + StartupTimeout time.Duration + LogOutput io.Writer +} + +type Manager struct { + cfg Config + cmd *exec.Cmd + mu sync.Mutex +} + +func New(cfg Config) (*Manager, error) { + if cfg.Binary == "" { + cfg.Binary = "nym-socks5-client" + } + if cfg.HomeDir == "" { + return nil, fmt.Errorf("nym home dir is required") + } + if cfg.ClientID == "" { + cfg.ClientID = "n2usenet" + } + if cfg.SocksAddr == "" { + cfg.SocksAddr = "127.0.0.1:11080" + } + if cfg.Provider == "" { + return nil, fmt.Errorf("nym provider is required") + } + if cfg.StartupTimeout <= 0 { + cfg.StartupTimeout = 120 * time.Second + } + if cfg.LogOutput == nil { + cfg.LogOutput = os.Stderr + } + return &Manager{cfg: cfg}, nil +} + +func (m *Manager) Init(ctx context.Context) error { + binary, err := exec.LookPath(m.cfg.Binary) + if err != nil { + if _, statErr := os.Stat(m.cfg.Binary); statErr != nil { + return fmt.Errorf("find nym-socks5-client: %w", err) + } + binary = m.cfg.Binary + } + m.cfg.Binary = binary + + if err := os.MkdirAll(m.cfg.HomeDir, 0700); err != nil { + return fmt.Errorf("create nym home: %w", err) + } + + configFile := filepath.Join(m.cfg.HomeDir, ".nym", "socks5-clients", m.cfg.ClientID, "config", "config.toml") + if data, err := os.ReadFile(configFile); err == nil { + if !strings.Contains(string(data), m.cfg.Provider) { + return fmt.Errorf("existing nym client config uses a different provider; remove %s to reinitialize intentionally", filepath.Dir(filepath.Dir(configFile))) + } + return nil + } + + host, port, err := net.SplitHostPort(m.cfg.SocksAddr) + if err != nil { + return fmt.Errorf("split socks addr: %w", err) + } + + args := []string{ + "init", + "--id", m.cfg.ClientID, + "--provider", m.cfg.Provider, + "--host", host, + "--port", port, + } + if m.cfg.AnonymousReplies { + args = append(args, "--use-reply-surbs", "true") + } + + cmd := exec.CommandContext(ctx, m.cfg.Binary, args...) + cmd.Env = append(os.Environ(), "HOME="+m.cfg.HomeDir) + cmd.Stdout = m.cfg.LogOutput + cmd.Stderr = m.cfg.LogOutput + if err := cmd.Run(); err != nil { + return fmt.Errorf("nym-socks5-client init: %w", err) + } + return nil +} + +func (m *Manager) Start(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.cmd != nil { + return nil + } + + host, port, err := net.SplitHostPort(m.cfg.SocksAddr) + if err != nil { + return fmt.Errorf("split socks addr: %w", err) + } + + args := []string{"run", "--id", m.cfg.ClientID, "--host", host, "--port", port} + if m.cfg.AnonymousReplies { + args = append(args, "--use-anonymous-replies", "true") + } + + cmd := exec.CommandContext(ctx, m.cfg.Binary, args...) + cmd.Env = append(os.Environ(), "HOME="+m.cfg.HomeDir) + cmd.Stdout = m.cfg.LogOutput + cmd.Stderr = m.cfg.LogOutput + if err := cmd.Start(); err != nil { + return fmt.Errorf("start nym-socks5-client: %w", err) + } + m.cmd = cmd + + go func() { + _ = cmd.Wait() + }() + + deadline := time.Now().Add(m.cfg.StartupTimeout) + for { + dialCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + conn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", m.cfg.SocksAddr) + cancel() + if err == nil { + _ = conn.Close() + return nil + } + if time.Now().After(deadline) { + m.Stop() + return fmt.Errorf("nym-socks5-client did not open %s within %s", m.cfg.SocksAddr, m.cfg.StartupTimeout) + } + select { + case <-ctx.Done(): + m.Stop() + return ctx.Err() + case <-time.After(500 * time.Millisecond): + } + } +} + +func (m *Manager) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + if m.cmd == nil || m.cmd.Process == nil { + return + } + _ = m.cmd.Process.Signal(os.Interrupt) + done := make(chan struct{}) + go func() { + _ = m.cmd.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + _ = m.cmd.Process.Kill() + } + m.cmd = nil +} diff --git a/internal/smtpclient/client.go b/internal/smtpclient/client.go new file mode 100644 index 0000000..d4ee686 --- /dev/null +++ b/internal/smtpclient/client.go @@ -0,0 +1,208 @@ +package smtpclient + +import ( + "bufio" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/textproto" + "strings" + "time" +) + +type DialContextFunc func(ctx context.Context, network, address string) (net.Conn, error) + +type Config struct { + Host string + Port int + Recipient string + EnvelopeFrom string + HELO string + TLSServerName string + RequireTLS bool + ImplicitTLS bool + Timeout time.Duration + DryRun bool +} + +type Client struct { + cfg Config + dial DialContextFunc +} + +type Message struct { + EnvelopeFrom string + Raw string +} + +func New(cfg Config, dial DialContextFunc) *Client { + return &Client{cfg: cfg, dial: dial} +} + +func (c *Client) Send(ctx context.Context, msg Message) error { + if c.cfg.DryRun { + return nil + } + if c.dial == nil { + c.dial = (&net.Dialer{}).DialContext + } + timeout := c.cfg.Timeout + if timeout <= 0 { + timeout = 90 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + addr := net.JoinHostPort(c.cfg.Host, fmt.Sprintf("%d", c.cfg.Port)) + conn, err := c.dial(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("dial smtp: %w", err) + } + defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + if c.cfg.ImplicitTLS { + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: c.cfg.TLSServerName, + MinVersion: tls.VersionTLS12, + }) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return fmt.Errorf("implicit tls handshake: %w", err) + } + conn = tlsConn + } + + session := newSession(conn) + if _, _, err := session.read(220); err != nil { + return fmt.Errorf("smtp greeting: %w", err) + } + + if err := session.ehlo(c.cfg.HELO); err != nil { + return err + } + + if c.cfg.RequireTLS && !c.cfg.ImplicitTLS { + if _, _, err := session.cmd(220, "STARTTLS\r\n"); err != nil { + return fmt.Errorf("starttls: %w", err) + } + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: c.cfg.TLSServerName, + MinVersion: tls.VersionTLS12, + }) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return fmt.Errorf("tls handshake: %w", err) + } + session = newSession(tlsConn) + if err := session.ehlo(c.cfg.HELO); err != nil { + return err + } + } + + envelopeFrom := sanitizeEnvelope(c.cfg.EnvelopeFrom) + if envelopeFrom == "" { + envelopeFrom = sanitizeEnvelope(msg.EnvelopeFrom) + } + if _, _, err := session.cmd(250, "MAIL FROM:<%s>\r\n", envelopeFrom); err != nil { + return fmt.Errorf("mail from rejected: %w", err) + } + if _, _, err := session.cmd(250, "RCPT TO:<%s>\r\n", sanitizeEnvelope(c.cfg.Recipient)); err != nil { + return fmt.Errorf("rcpt to rejected: %w", err) + } + if _, _, err := session.cmd(354, "DATA\r\n"); err != nil { + return fmt.Errorf("data rejected: %w", err) + } + if err := writeSMTPData(session.w, msg.Raw); err != nil { + return err + } + if _, _, err := session.read(250); err != nil { + return fmt.Errorf("message rejected: %w", err) + } + _, _, _ = session.cmd(221, "QUIT\r\n") + return nil +} + +type session struct { + conn net.Conn + tp *textproto.Reader + w *bufio.Writer +} + +func newSession(conn net.Conn) *session { + reader := bufio.NewReader(conn) + return &session{ + conn: conn, + tp: textproto.NewReader(reader), + w: bufio.NewWriter(conn), + } +} + +func (s *session) ehlo(helo string) error { + if helo == "" { + helo = "n2usenet.local" + } + if _, _, err := s.cmd(250, "EHLO %s\r\n", sanitizeAtom(helo)); err != nil { + if _, _, heloErr := s.cmd(250, "HELO %s\r\n", sanitizeAtom(helo)); heloErr != nil { + return fmt.Errorf("ehlo failed: %w", err) + } + } + return nil +} + +func (s *session) cmd(expect int, format string, args ...any) (int, string, error) { + if _, err := fmt.Fprintf(s.w, format, args...); err != nil { + return 0, "", err + } + if err := s.w.Flush(); err != nil { + return 0, "", err + } + return s.read(expect) +} + +func (s *session) read(expect int) (int, string, error) { + code, msg, err := s.tp.ReadResponse(expect) + if err != nil { + return code, msg, err + } + return code, msg, nil +} + +func writeSMTPData(w *bufio.Writer, raw string) error { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + for _, line := range strings.Split(raw, "\n") { + if strings.HasPrefix(line, ".") { + line = "." + line + } + if _, err := io.WriteString(w, line+"\r\n"); err != nil { + return fmt.Errorf("write smtp data: %w", err) + } + } + if _, err := io.WriteString(w, ".\r\n"); err != nil { + return fmt.Errorf("write smtp terminator: %w", err) + } + if err := w.Flush(); err != nil { + return fmt.Errorf("flush smtp data: %w", err) + } + return nil +} + +func sanitizeEnvelope(v string) string { + v = strings.TrimSpace(v) + v = strings.ReplaceAll(v, "\r", "") + v = strings.ReplaceAll(v, "\n", "") + v = strings.Trim(v, "<>") + return v +} + +func sanitizeAtom(v string) string { + v = strings.TrimSpace(v) + v = strings.ReplaceAll(v, "\r", "") + v = strings.ReplaceAll(v, "\n", "") + if v == "" { + return "n2usenet.local" + } + return v +} diff --git a/internal/smtpclient/client_test.go b/internal/smtpclient/client_test.go new file mode 100644 index 0000000..d9eec6d --- /dev/null +++ b/internal/smtpclient/client_test.go @@ -0,0 +1,93 @@ +package smtpclient + +import ( + "bufio" + "context" + "net" + "strings" + "testing" + "time" +) + +func TestSendUsesConfiguredEnvelopeFrom(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + + commands := make(chan string, 32) + done := make(chan struct{}) + go func() { + defer close(done) + defer close(commands) + defer server.Close() + + r := bufio.NewReader(server) + w := bufio.NewWriter(server) + _, _ = w.WriteString("220 test\r\n") + _ = w.Flush() + + inData := false + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + commands <- line + + switch { + case inData && line != ".\r\n": + continue + case strings.HasPrefix(line, "EHLO "): + _, _ = w.WriteString("250 test\r\n") + case strings.HasPrefix(line, "MAIL FROM:"): + _, _ = w.WriteString("250 ok\r\n") + case strings.HasPrefix(line, "RCPT TO:"): + _, _ = w.WriteString("250 ok\r\n") + case strings.HasPrefix(line, "DATA"): + inData = true + _, _ = w.WriteString("354 go ahead\r\n") + case line == ".\r\n": + inData = false + _, _ = w.WriteString("250 queued\r\n") + case strings.HasPrefix(line, "QUIT"): + _, _ = w.WriteString("221 bye\r\n") + _ = w.Flush() + return + default: + continue + } + _ = w.Flush() + } + }() + + mailer := New(Config{ + Host: "mail2news.tcpreset.net", + Port: 25, + Recipient: "mail2news@mail2news.tcpreset.net", + EnvelopeFrom: "n2usenet@virebent.art", + HELO: "n2usenet.virebent.art", + RequireTLS: false, + Timeout: 5 * time.Second, + }, func(ctx context.Context, network, address string) (net.Conn, error) { + return client, nil + }) + + if err := mailer.Send(context.Background(), Message{ + EnvelopeFrom: "user@example.invalid", + Raw: "From: User \r\nTo: mail2news@mail2news.tcpreset.net\r\nSubject: Test\r\n\r\nhello", + }); err != nil { + t.Fatalf("Send returned error: %v", err) + } + + var sawConfiguredFrom bool + for line := range commands { + if line == "MAIL FROM:\r\n" { + sawConfiguredFrom = true + break + } + } + <-done + + if !sawConfiguredFrom { + t.Fatal("configured envelope sender was not used") + } +} diff --git a/internal/socks5/dialer.go b/internal/socks5/dialer.go new file mode 100644 index 0000000..8f74840 --- /dev/null +++ b/internal/socks5/dialer.go @@ -0,0 +1,131 @@ +package socks5 + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "net" + "strconv" + "time" +) + +type Dialer struct { + ProxyAddr string + Timeout time.Duration +} + +func (d Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if network != "tcp" { + return nil, fmt.Errorf("socks5 only supports tcp, got %s", network) + } + host, portText, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("split target address: %w", err) + } + port, err := strconv.Atoi(portText) + if err != nil || port < 1 || port > 65535 { + return nil, fmt.Errorf("invalid target port") + } + if len(host) == 0 || len(host) > 255 { + return nil, fmt.Errorf("invalid target host length") + } + timeout := d.Timeout + if timeout <= 0 { + timeout = 90 * time.Second + } + conn, err := (&net.Dialer{Timeout: timeout}).DialContext(ctx, "tcp", d.ProxyAddr) + if err != nil { + return nil, fmt.Errorf("connect socks proxy: %w", err) + } + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } else { + _ = conn.SetDeadline(time.Now().Add(timeout)) + } + if err := handshake(conn, host, uint16(port)); err != nil { + _ = conn.Close() + return nil, err + } + _ = conn.SetDeadline(time.Time{}) + return conn, nil +} + +func handshake(conn net.Conn, host string, port uint16) error { + if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { + return fmt.Errorf("write socks greeting: %w", err) + } + var greeting [2]byte + if _, err := io.ReadFull(conn, greeting[:]); err != nil { + return fmt.Errorf("read socks greeting: %w", err) + } + if greeting[0] != 0x05 || greeting[1] != 0x00 { + return fmt.Errorf("socks proxy rejected no-auth method") + } + + req := make([]byte, 0, 7+len(host)) + req = append(req, 0x05, 0x01, 0x00, 0x03, byte(len(host))) + req = append(req, []byte(host)...) + var p [2]byte + binary.BigEndian.PutUint16(p[:], port) + req = append(req, p[:]...) + if _, err := conn.Write(req); err != nil { + return fmt.Errorf("write socks connect: %w", err) + } + + var head [4]byte + if _, err := io.ReadFull(conn, head[:]); err != nil { + return fmt.Errorf("read socks response: %w", err) + } + if head[0] != 0x05 { + return fmt.Errorf("invalid socks response version") + } + if head[1] != 0x00 { + return fmt.Errorf("socks connect failed: %s", replyText(head[1])) + } + var skip int + switch head[3] { + case 0x01: + skip = 4 + 2 + case 0x03: + var l [1]byte + if _, err := io.ReadFull(conn, l[:]); err != nil { + return fmt.Errorf("read socks bind host length: %w", err) + } + skip = int(l[0]) + 2 + case 0x04: + skip = 16 + 2 + default: + return fmt.Errorf("unsupported socks bind address type") + } + if skip > 0 { + buf := make([]byte, skip) + if _, err := io.ReadFull(conn, buf); err != nil { + return fmt.Errorf("read socks bind address: %w", err) + } + } + return nil +} + +func replyText(code byte) string { + switch code { + case 0x01: + return "general failure" + case 0x02: + return "connection not allowed" + case 0x03: + return "network unreachable" + case 0x04: + return "host unreachable" + case 0x05: + return "connection refused" + case 0x06: + return "ttl expired" + case 0x07: + return "command not supported" + case 0x08: + return "address type not supported" + default: + return fmt.Sprintf("unknown code %d", code) + } +} diff --git a/internal/storage/replay.go b/internal/storage/replay.go new file mode 100644 index 0000000..a2366a4 --- /dev/null +++ b/internal/storage/replay.go @@ -0,0 +1,32 @@ +package storage + +import ( + "sync" + "time" +) + +type ReplayCache struct { + ttl time.Duration + mu sync.Mutex + data map[string]time.Time +} + +func NewReplayCache(ttl time.Duration) *ReplayCache { + return &ReplayCache{ttl: ttl, data: map[string]time.Time{}} +} + +func (c *ReplayCache) CheckAndMark(hash string) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + now := time.Now() + for k, ts := range c.data { + if now.Sub(ts) > c.ttl { + delete(c.data, k) + } + } + if _, exists := c.data[hash]; exists { + return true, nil + } + c.data[hash] = now + return false, nil +} diff --git a/internal/storage/replay_test.go b/internal/storage/replay_test.go new file mode 100644 index 0000000..4d147b8 --- /dev/null +++ b/internal/storage/replay_test.go @@ -0,0 +1,36 @@ +package storage + +import ( + "testing" + "time" +) + +func TestReplayCacheCheckAndMarkLivesOnlyInMemory(t *testing.T) { + cache := NewReplayCache(40 * time.Millisecond) + + replayed, err := cache.CheckAndMark("token-hash") + if err != nil { + t.Fatalf("CheckAndMark returned error: %v", err) + } + if replayed { + t.Fatal("first token use should not be marked as replayed") + } + + replayed, err = cache.CheckAndMark("token-hash") + if err != nil { + t.Fatalf("CheckAndMark returned error on second use: %v", err) + } + if !replayed { + t.Fatal("second token use should be marked as replayed") + } + + time.Sleep(60 * time.Millisecond) + + replayed, err = cache.CheckAndMark("token-hash") + if err != nil { + t.Fatalf("CheckAndMark returned error after ttl expiry: %v", err) + } + if replayed { + t.Fatal("expired token entry should be forgotten from memory") + } +} 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, "Request failed

Request failed

%s

Back

", 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, "Message sent

Message sent

Message-ID:

%s

Send another

", 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{}, "") + 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, "") + + 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"] != "" { + 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<") + 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 ") + 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 ", + 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 "" +} -- cgit v1.2.3