diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/assets/assets.go | 6 | ||||
| -rw-r--r-- | internal/assets/web/static/Nym.ico | bin | 0 -> 1406 bytes | |||
| -rw-r--r-- | internal/assets/web/static/nacl-util.min.js | 1 | ||||
| -rw-r--r-- | internal/assets/web/static/nacl.min.js | 1 | ||||
| -rw-r--r-- | internal/assets/web/static/powWorker.js | 57 | ||||
| -rw-r--r-- | internal/assets/web/static/style.css | 150 | ||||
| -rw-r--r-- | internal/assets/web/templates/index.html | 1577 | ||||
| -rw-r--r-- | internal/config/config.go | 209 | ||||
| -rw-r--r-- | internal/nymclient/manager.go | 176 | ||||
| -rw-r--r-- | internal/smtpclient/client.go | 208 | ||||
| -rw-r--r-- | internal/smtpclient/client_test.go | 93 | ||||
| -rw-r--r-- | internal/socks5/dialer.go | 131 | ||||
| -rw-r--r-- | internal/storage/replay.go | 32 | ||||
| -rw-r--r-- | internal/storage/replay_test.go | 36 | ||||
| -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 |
19 files changed, 3571 insertions, 0 deletions
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 Binary files differnew file mode 100644 index 0000000..26a2943 --- /dev/null +++ b/internal/assets/web/static/Nym.ico 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<r.length;n++)t[n]=r.charCodeAt(n);return t},e.encodeUTF8=function(e){var n,r=[];for(n=0;n<e.length;n++)r.push(String.fromCharCode(e[n]));return decodeURIComponent(escape(r.join("")))},"undefined"==typeof atob?void 0!==Buffer.from?(e.encodeBase64=function(e){return Buffer.from(e).toString("base64")},e.decodeBase64=function(e){return o(e),new Uint8Array(Array.prototype.slice.call(Buffer.from(e,"base64"),0))}):(e.encodeBase64=function(e){return new Buffer(e).toString("base64")},e.decodeBase64=function(e){return o(e),new Uint8Array(Array.prototype.slice.call(new Buffer(e,"base64"),0))}):(e.encodeBase64=function(e){var n,r=[],t=e.length;for(n=0;n<t;n++)r.push(String.fromCharCode(e[n]));return btoa(r.join(""))},e.decodeBase64=function(e){o(e);var n,r=atob(e),t=new Uint8Array(r.length);for(n=0;n<r.length;n++)t[n]=r.charCodeAt(n);return t}),e});
\ No newline at end of file diff --git a/internal/assets/web/static/nacl.min.js b/internal/assets/web/static/nacl.min.js new file mode 100644 index 0000000..65340cc --- /dev/null +++ b/internal/assets/web/static/nacl.min.js @@ -0,0 +1 @@ +!function(i){"use strict";var m=function(r,n){this.hi=0|r,this.lo=0|n},v=function(r){var n,e=new Float64Array(16);if(r)for(n=0;n<r.length;n++)e[n]=r[n];return e},a=function(){throw new Error("no PRNG")},o=new Uint8Array(16),e=new Uint8Array(32);e[0]=9;var c=v(),w=v([1]),g=v([56129,1]),y=v([30883,4953,19914,30187,55467,16705,2637,112,59544,30585,16505,36039,65139,11119,27886,20995]),l=v([61785,9906,39828,60374,45398,33411,5274,224,53552,61171,33010,6542,64743,22239,55772,9222]),t=v([54554,36645,11616,51542,42930,38181,51040,26924,56412,64982,57905,49316,21502,52590,14035,8553]),f=v([26200,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214]),s=v([41136,18958,6951,50414,58488,44335,6150,12099,55207,15867,153,11085,57099,20417,9344,11139]);function h(r,n){return r<<n|r>>>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<o;i++)a|=r[n+i]^e[t+i];return(1&a-1>>>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<o)for(E(w,c,a,d),u=0;u<o;u++)r[n+u]=(e?e[t+u]:0)^w[u];return 0}function Y(r,n,e,t,o){return K(r,n,null,0,e,t,o)}function L(r,n,e,t,o){var i=new Uint8Array(32);return x(i,t,o,d),Y(r,n,e,t.subarray(16),i)}function T(r,n,e,t,o,i,a){var f=new Uint8Array(32);return x(f,i,a,d),K(r,n,e,t,o,i.subarray(16),f)}function k(r,n){var e,t=0;for(e=0;e<17;e++)t=t+(r[e]+n[e]|0)|0,r[e]=255&t,t>>>=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<o;){for(u=0;u<17;u++)s[u]=0;for(u=0;u<16&&u<o;++u)s[u]=e[t+u];for(s[u]=1,t+=u,o-=u,k(l,s),f=0;f<17;f++)for(u=w[f]=0;u<17;u++)w[f]=w[f]+l[u]*(u<=f?y[f-u]:320*y[f+17-u]|0)|0;for(f=0;f<17;f++)l[f]=w[f];for(u=c=0;u<16;u++)c=c+l[u]|0,l[u]=255&c,c>>>=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<arguments.length;e++)t+=(r=arguments[e].lo)&f,o+=r>>>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<arguments.length;r++)n^=arguments[r].lo,e^=arguments[r].hi;return new m(e,n)}function ar(r,n){var e,t,o=32-n;return n<32?(e=r.hi>>>n|r.lo<<o,t=r.lo>>>n|r.hi<<o):n<64&&(e=r.lo>>>n|r.hi<<o,t=r.hi>>>n|r.lo<<o),new m(e,t)}var fr=[new m(1116352408,3609767458),new m(1899447441,602891725),new m(3049323471,3964484399),new m(3921009573,2173295548),new m(961987163,4081628472),new m(1508970993,3053834265),new m(2453635748,2937671579),new m(2870763221,3664609560),new m(3624381080,2734883394),new m(310598401,1164996542),new m(607225278,1323610764),new m(1426881987,3590304994),new m(1925078388,4068182383),new m(2162078206,991336113),new m(2614888103,633803317),new m(3248222580,3479774868),new m(3835390401,2666613458),new m(4022224774,944711139),new m(264347078,2341262773),new m(604807628,2007800933),new m(770255983,1495990901),new m(1249150122,1856431235),new m(1555081692,3175218132),new m(1996064986,2198950837),new m(2554220882,3999719339),new m(2821834349,766784016),new m(2952996808,2566594879),new m(3210313671,3203337956),new m(3336571891,1034457026),new m(3584528711,2466948901),new m(113926993,3758326383),new m(338241895,168717936),new m(666307205,1188179964),new m(773529912,1546045734),new m(1294757372,1522805485),new m(1396182291,2643833823),new m(1695183700,2343527390),new m(1986661051,1014477480),new m(2177026350,1206759142),new m(2456956037,344077627),new m(2730485921,1290863460),new m(2820302411,3158454273),new m(3259730800,3505952657),new m(3345764771,106217008),new m(3516065817,3606008344),new m(3600352804,1432725776),new m(4094571909,1467031594),new m(275423344,851169720),new m(430227734,3100823752),new m(506948616,1363258195),new m(659060556,3750685593),new m(883997877,3785050280),new m(958139571,3318307427),new m(1322822218,3812723403),new m(1537002063,2003034995),new m(1747873779,3602036899),new m(1955562222,1575990012),new m(2024104815,1125592928),new m(2227730452,2716904306),new m(2361852424,442776044),new m(2428436474,593698344),new m(2756734187,3733110249),new m(3204031479,2999351573),new m(3329325298,3815920427),new m(3391569614,3928383900),new m(3515267271,566280711),new m(3940187606,3454069534),new m(4118630271,4000239992),new m(116418474,1914138554),new m(174292421,2731055270),new m(289380356,3203993006),new m(460393269,320620315),new m(685471733,587496836),new m(852142971,1086792851),new m(1017036298,365543100),new m(1126000580,2618297676),new m(1288033470,3409855158),new m(1501505948,4234509866),new m(1607167915,987167468),new m(1816402316,1246189591)];function ur(r,n,e){var t,o,i,a=[],f=[],u=[],c=[];for(o=0;o<8;o++)a[o]=u[o]=B(r,8*o);for(var w,y,l,s,h,v,g,b,p,A,_,U,E,x,d=0;128<=e;){for(o=0;o<16;o++)c[o]=B(n,8*o+d);for(o=0;o<80;o++){for(i=0;i<8;i++)f[i]=u[i];for(t=tr(u[7],ir(ar(x=u[4],14),ar(x,18),ar(x,41)),(p=u[4],A=u[5],_=u[6],0,U=p.hi&A.hi^~p.hi&_.hi,E=p.lo&A.lo^~p.lo&_.lo,new m(U,E)),fr[o],c[o%16]),f[7]=tr(t,ir(ar(b=u[0],28),ar(b,34),ar(b,39)),(l=u[0],s=u[1],h=u[2],0,v=l.hi&s.hi^l.hi&h.hi^s.hi&h.hi,g=l.lo&s.lo^l.lo&h.lo^s.lo&h.lo,new m(v,g))),f[3]=tr(f[3],t),i=0;i<8;i++)u[(i+1)%8]=f[i];if(o%16==15)for(i=0;i<16;i++)c[i]=tr(c[i],c[(i+9)%16],ir(ar(y=c[(i+1)%16],1),ar(y,8),or(y,7)),ir(ar(w=c[(i+14)%16],19),ar(w,61),or(w,6)))}for(o=0;o<8;o++)u[o]=tr(u[o],a[o]),a[o]=u[o];d+=128,e-=128}for(o=0;o<8;o++)S(r,8*o,a[o]);return e}var cr=new Uint8Array([106,9,230,103,243,188,201,8,187,103,174,133,132,202,167,59,60,110,243,114,254,148,248,43,165,79,245,58,95,29,54,241,81,14,82,127,173,230,130,209,155,5,104,140,43,62,108,31,31,131,217,171,251,65,189,107,91,224,205,25,19,126,33,121]);function wr(r,n,e){var t,o=new Uint8Array(64),i=new Uint8Array(256),a=e;for(t=0;t<64;t++)o[t]=cr[t];for(ur(o,n,e),e%=128,t=0;t<256;t++)i[t]=0;for(t=0;t<e;t++)i[t]=n[a-e+t];for(i[e]=128,i[(e=256-128*(e<112?1:0))-9]=0,S(i,e-8,new m(a/536870912|0,a<<3)),ur(o,i,e),t=0;t<64;t++)r[t]=o[t];return 0}function yr(r,n){var e=v(),t=v(),o=v(),i=v(),a=v(),f=v(),u=v(),c=v(),w=v();V(e,r[1],r[0]),V(w,n[1],n[0]),X(e,e,w),I(t,r[0],r[1]),I(w,n[0],n[1]),X(t,t,w),X(o,r[3],n[3]),X(o,o,l),X(i,r[2],n[2]),I(i,i,i),V(a,t,e),V(f,i,o),I(u,i,o),I(c,t,e),X(r[0],a,f),X(r[1],c,u),X(r[2],u,f),X(r[3],a,c)}function lr(r,n,e){var t;for(t=0;t<4;t++)F(r[t],n[t],e)}function sr(r,n){var e=v(),t=v(),o=v();H(o,n[2]),X(e,n[0],o),X(t,n[1],o),Z(r,t),r[31]^=q(e)<<7}function hr(r,n,e){var t,o;for(O(r[0],c),O(r[1],w),O(r[2],w),O(r[3],c),o=255;0<=o;--o)lr(r,n,t=e[o/8|0]>>(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<i;++o)n[o]+=e-16*n[t]*br[o-(t-32)],e=Math.floor((n[o]+128)/256),n[o]-=256*e;n[o]+=e,n[t]=0}for(o=e=0;o<32;o++)n[o]+=e-(n[31]>>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<e;o++)r[64+o]=n[o];for(o=0;o<32;o++)r[32+o]=a[32+o];for(wr(u,r.subarray(32),e+32),Ar(u),vr(w,u),sr(r,w),o=32;o<64;o++)r[o]=t[o];for(wr(f,r,e+64),Ar(f),o=0;o<64;o++)c[o]=0;for(o=0;o<32;o++)c[o]=u[o];for(o=0;o<32;o++)for(i=0;i<32;i++)c[o+i]+=f[o]*a[i];return pr(r.subarray(32),c),y}function Ur(r,n,e,t){var o,i=new Uint8Array(32),a=new Uint8Array(64),f=[v(),v(),v(),v()],u=[v(),v(),v(),v()];if(e<64)return-1;if(function(r,n){var e=v(),t=v(),o=v(),i=v(),a=v(),f=v(),u=v();if(O(r[2],w),D(r[1],n),j(o,r[1]),X(i,o,y),V(o,o,r[2]),I(i,r[2],i),j(a,i),j(f,a),X(u,f,a),X(e,u,o),X(e,e,i),J(e,e),X(e,e,o),X(e,e,i),X(e,e,i),X(r[0],e,i),j(t,r[0]),X(t,t,i),G(t,o)&&X(r[0],r[0],s),j(t,r[0]),X(t,t,i),G(t,o))return 1;q(r[0])===n[31]>>7&&V(r[0],c,r[0]),X(r[3],r[0],r[1])}(u,t))return-1;for(o=0;o<e;o++)r[o]=n[o];for(o=0;o<32;o++)r[o+32]=t[o];if(wr(a,r,e),Ar(a),hr(f,u,a),vr(u,n.subarray(32)),yr(f,u),sr(i,f),e-=64,_(n,0,i,0)){for(o=0;o<e;o++)r[o]=0;return-1}for(o=0;o<e;o++)r[o]=n[o+64];return e}function Er(r,n){if(32!==r.length)throw new Error("bad key size");if(24!==n.length)throw new Error("bad nonce size")}function xr(){for(var r=0;r<arguments.length;r++)if(!(arguments[r]instanceof Uint8Array))throw new TypeError("unexpected type, use Uint8Array")}function dr(r){for(var n=0;n<r.length;n++)r[n]=0}i.lowlevel={crypto_core_hsalsa20:x,crypto_stream_xor:T,crypto_stream:L,crypto_stream_salsa20_xor:K,crypto_stream_salsa20:Y,crypto_onetimeauth:R,crypto_onetimeauth_verify:P,crypto_verify_16:A,crypto_verify_32:_,crypto_secretbox:M,crypto_secretbox_open:N,crypto_scalarmult:Q,crypto_scalarmult_base:W,crypto_box_beforenm:rr,crypto_box_afternm:nr,crypto_box:function(r,n,e,t,o,i){var a=new Uint8Array(32);return rr(a,o,i),nr(r,n,e,t,a)},crypto_box_open:function(r,n,e,t,o,i){var a=new Uint8Array(32);return rr(a,o,i),er(r,n,e,t,a)},crypto_box_keypair:$,crypto_hash:wr,crypto_sign:_r,crypto_sign_keypair:gr,crypto_sign_open:Ur,crypto_secretbox_KEYBYTES:32,crypto_secretbox_NONCEBYTES:24,crypto_secretbox_ZEROBYTES:32,crypto_secretbox_BOXZEROBYTES:16,crypto_scalarmult_BYTES:32,crypto_scalarmult_SCALARBYTES:32,crypto_box_PUBLICKEYBYTES:32,crypto_box_SECRETKEYBYTES:32,crypto_box_BEFORENMBYTES:32,crypto_box_NONCEBYTES:24,crypto_box_ZEROBYTES:32,crypto_box_BOXZEROBYTES:16,crypto_sign_BYTES:64,crypto_sign_PUBLICKEYBYTES:32,crypto_sign_SECRETKEYBYTES:64,crypto_sign_SEEDBYTES:32,crypto_hash_BYTES:64,gf:v,D:y,L:br,pack25519:Z,unpack25519:D,M:X,A:I,S:j,Z:V,pow2523:J,add:yr,set25519:O,modL:pr,scalarmult:hr,scalarbase:vr},i.randomBytes=function(r){var n=new Uint8Array(r);return a(n,r),n},i.secretbox=function(r,n,e){xr(r,n,e),Er(e,n);for(var t=new Uint8Array(32+r.length),o=new Uint8Array(t.length),i=0;i<r.length;i++)t[i+32]=r[i];return M(o,t,t.length,n,e),o.subarray(16)},i.secretbox.open=function(r,n,e){xr(r,n,e),Er(e,n);for(var t=new Uint8Array(16+r.length),o=new Uint8Array(t.length),i=0;i<r.length;i++)t[i+16]=r[i];return t.length<32||0!==N(o,t,t.length,n,e)?null:o.subarray(32)},i.secretbox.keyLength=32,i.secretbox.nonceLength=24,i.secretbox.overheadLength=16,i.scalarMult=function(r,n){if(xr(r,n),32!==r.length)throw new Error("bad n size");if(32!==n.length)throw new Error("bad p size");var e=new Uint8Array(32);return Q(e,r,n),e},i.scalarMult.base=function(r){if(xr(r),32!==r.length)throw new Error("bad n size");var n=new Uint8Array(32);return W(n,r),n},i.scalarMult.scalarLength=32,i.scalarMult.groupElementLength=32,i.box=function(r,n,e,t){var o=i.box.before(e,t);return i.secretbox(r,n,o)},i.box.before=function(r,n){xr(r,n),function(r,n){if(32!==r.length)throw new Error("bad public key size");if(32!==n.length)throw new Error("bad secret key size")}(r,n);var e=new Uint8Array(32);return rr(e,r,n),e},i.box.after=i.secretbox,i.box.open=function(r,n,e,t){var o=i.box.before(e,t);return i.secretbox.open(r,n,o)},i.box.open.after=i.secretbox.open,i.box.keyPair=function(){var r=new Uint8Array(32),n=new Uint8Array(32);return $(r,n),{publicKey:r,secretKey:n}},i.box.keyPair.fromSecretKey=function(r){if(xr(r),32!==r.length)throw new Error("bad secret key size");var n=new Uint8Array(32);return W(n,r),{publicKey:n,secretKey:new Uint8Array(r)}},i.box.publicKeyLength=32,i.box.secretKeyLength=32,i.box.sharedKeyLength=32,i.box.nonceLength=24,i.box.overheadLength=i.secretbox.overheadLength,i.sign=function(r,n){if(xr(r,n),64!==n.length)throw new Error("bad secret key size");var e=new Uint8Array(64+r.length);return _r(e,r,r.length,n),e},i.sign.open=function(r,n){if(xr(r,n),32!==n.length)throw new Error("bad public key size");var e=new Uint8Array(r.length),t=Ur(e,r,r.length,n);if(t<0)return null;for(var o=new Uint8Array(t),i=0;i<o.length;i++)o[i]=e[i];return o},i.sign.detached=function(r,n){for(var e=i.sign(r,n),t=new Uint8Array(64),o=0;o<t.length;o++)t[o]=e[o];return t},i.sign.detached.verify=function(r,n,e){if(xr(r,n,e),64!==n.length)throw new Error("bad signature size");if(32!==e.length)throw new Error("bad public key size");var t,o=new Uint8Array(64+r.length),i=new Uint8Array(64+r.length);for(t=0;t<64;t++)o[t]=n[t];for(t=0;t<r.length;t++)o[t+64]=r[t];return 0<=Ur(i,o,o.length,e)},i.sign.keyPair=function(){var r=new Uint8Array(32),n=new Uint8Array(64);return gr(r,n),{publicKey:r,secretKey:n}},i.sign.keyPair.fromSecretKey=function(r){if(xr(r),64!==r.length)throw new Error("bad secret key size");for(var n=new Uint8Array(32),e=0;e<n.length;e++)n[e]=r[32+e];return{publicKey:n,secretKey:new Uint8Array(r)}},i.sign.keyPair.fromSeed=function(r){if(xr(r),32!==r.length)throw new Error("bad seed size");for(var n=new Uint8Array(32),e=new Uint8Array(64),t=0;t<32;t++)e[t]=r[t];return gr(n,e,!0),{publicKey:n,secretKey:e}},i.sign.publicKeyLength=32,i.sign.secretKeyLength=64,i.sign.seedLength=32,i.sign.signatureLength=64,i.hash=function(r){xr(r);var n=new Uint8Array(64);return wr(n,r,r.length),n},i.hash.hashLength=64,i.verify=function(r,n){return xr(r,n),0!==r.length&&0!==n.length&&(r.length===n.length&&0===u(r,0,n,0,r.length))},i.setPRNG=function(r){a=r},function(){var o="undefined"!=typeof self?self.crypto||self.msCrypto:null;if(o&&o.getRandomValues){i.setPRNG(function(r,n){var e,t=new Uint8Array(n);for(e=0;e<n;e+=65536)o.getRandomValues(t.subarray(e,e+Math.min(n-e,65536)));for(e=0;e<n;e++)r[e]=t[e];dr(t)})}else"undefined"!=typeof require&&(o=require("crypto"))&&o.randomBytes&&i.setPRNG(function(r,n){var e,t=o.randomBytes(n);for(e=0;e<n;e++)r[e]=t[e];dr(t)})}()}("undefined"!=typeof module&&module.exports?module.exports:self.nacl=self.nacl||{});
\ No newline at end of file diff --git a/internal/assets/web/static/powWorker.js b/internal/assets/web/static/powWorker.js new file mode 100644 index 0000000..fa668a1 --- /dev/null +++ b/internal/assets/web/static/powWorker.js @@ -0,0 +1,57 @@ +self.onmessage = async ({ data }) => { + 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 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>N2Usenet Gateway v2.7 Nym</title> + <link rel="icon" href="/favicon.ico" sizes="any"> + <link rel="shortcut icon" href="/favicon.ico"> + <style> + :root { + --background: #f9f9f9; + --card-bg: #fff; + --text: #333; + --border: #ccc; + --primary: #4caf50; + --primary-hover: #45a049; + --tab-bg: #eee; + --input-bg: #fff; + --input-readonly: #eee; + --progress-bg: #ddd; + --success: #28a745; + --warning: #ffc107; + --error: #dc3545; + } + + .dark-theme { + --background: #1a1a1a; + --card-bg: #2c2c2c; + --text: #e0e0e0; + --border: #444; + --primary: #5cbb60; + --primary-hover: #4caf50; + --tab-bg: #333; + --input-bg: #3c3c3c; + --input-readonly: #2a2a2a; + --progress-bg: #444; + } + + body { + background: var(--background); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + margin: 0; + padding: 0; + transition: background 0.3s ease; + } + + .container { + max-width: 800px; + margin: auto; + padding: 20px; + } + + header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + } + + .theme-toggle { + display: flex; + align-items: center; + gap: 8px; + } + + .toggle { + position: relative; + width: 60px; + height: 30px; + background: #ccc; + border-radius: 30px; + padding: 4px; + cursor: pointer; + transition: 0.3s; + } + + .toggle:before { + content: ''; + position: absolute; + width: 26px; + height: 26px; + border-radius: 50%; + background: white; + top: 2px; + left: 2px; + transition: 0.3s; + } + + input[type="checkbox"]:checked + .toggle { + background: #4caf50; + } + + input[type="checkbox"]:checked + .toggle:before { + transform: translateX(30px); + } + + input[type="checkbox"] { + display: none; + } + + .notification { + position: fixed; + top: 20px; + right: 20px; + max-width: 400px; + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 10000; + animation: slideIn 0.3s ease; + display: flex; + align-items: center; + gap: 10px; + } + + .notification.success { background: var(--success); color: white; } + .notification.error { background: var(--error); color: white; } + .notification.warning { background: var(--warning); color: #333; } + .notification.info { background: #007bff; color: white; } + + @keyframes slideIn { + from { transform: translateX(400px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + + @keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(156, 39, 176, 0.7); } + 50% { box-shadow: 0 0 0 15px rgba(156, 39, 176, 0); } + 100% { box-shadow: 0 0 0 0 rgba(156, 39, 176, 0); } + } + + .notification-icon { font-size: 1.5em; } + .notification-close { margin-left: auto; cursor: pointer; font-size: 1.2em; opacity: 0.8; } + .notification-close:hover { opacity: 1; } + + .section-info { + background: var(--card-bg); + border-left: 4px solid var(--primary); + padding: 10px 15px; + margin-bottom: 15px; + border-radius: 0 4px 4px 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .tabs { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 15px; + } + + .tabs button { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 15px; + border: none; + background: var(--tab-bg); + color: var(--text); + cursor: pointer; + border-radius: 4px; + font-weight: bold; + transition: background 0.2s, transform 0.1s; + position: relative; + } + + .tabs button:hover { background: var(--primary); color: white; } + .tabs button:active { transform: scale(0.98); } + + .tabs button.completed::after { + content: '✓'; + position: absolute; + right: 15px; + background: var(--success); + color: white; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9em; + } + + .tab-content { + display: none !important; + padding: 20px; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + margin-bottom: 20px; + } + + .tab-content.active { + display: block !important; + } + + label { + display: block; + margin-top: 15px; + font-weight: bold; + margin-bottom: 5px; + } + + input, textarea, select { + width: 100%; + padding: 10px; + margin-top: 5px; + box-sizing: border-box; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--input-bg); + color: var(--text); + font-family: inherit; + } + + input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); + } + + input.error, textarea.error { border-color: var(--error); } + + input[readonly], textarea[readonly] { + background: var(--input-readonly); + cursor: not-allowed; + border: 2px solid #999; + } + + input.prefilled { + background: #e8f5e9; + border: 2px solid var(--primary); + } + + .dark-theme input.prefilled { + background: #1b3d1b; + } + + button { + margin-top: 15px; + padding: 12px 20px; + background: var(--primary); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + transition: background 0.2s, opacity 0.2s; + font-family: inherit; + } + + button:hover:not(:disabled) { background: var(--primary-hover); } + button:disabled { opacity: 0.6; cursor: not-allowed; } + + .btn-secondary { + background: #6c757d; + } + .btn-secondary:hover:not(:disabled) { background: #5a6268; } + + .progress-bar { + width: 100%; + background: var(--progress-bg); + border-radius: 5px; + overflow: hidden; + height: 20px; + margin-top: 10px; + } + + .progress-bar-inner { + height: 100%; + width: 0; + background: var(--primary); + text-align: center; + color: white; + line-height: 20px; + transition: width 0.3s; + font-size: 0.85em; + } + + .output-field { + margin-top: 15px; + padding: 10px; + background: var(--input-readonly); + border: 1px solid var(--border); + border-radius: 4px; + word-break: break-all; + min-height: 30px; + font-family: monospace; + font-size: 0.9em; + } + + .output-field.empty { color: #999; font-style: italic; } + + .gateway-info { + background: var(--card-bg); + border: 1px solid var(--border); + padding: 15px; + margin: 15px 0; + border-radius: 4px; + border-left: 4px solid #007bff; + } + + .gateway-item { + display: flex; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border); + } + + .gateway-item:last-child { border-bottom: none; } + + .gateway-priority { + background: #007bff; + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.8em; + margin-right: 10px; + min-width: 70px; + text-align: center; + } + + .gateway-address { + font-family: monospace; + background: var(--input-readonly); + padding: 4px 8px; + border-radius: 4px; + flex: 1; + font-size: 0.85em; + } + + .prefill-banner { + background: linear-gradient(135deg, #4caf50 0%, #45a049 100%); + color: white; + padding: 12px 15px; + border-radius: 4px; + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 10px; + box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3); + } + + .prefill-banner.reply { + background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); + } + + .prefill-banner-icon { font-size: 1.5em; } + .prefill-banner-text { flex: 1; } + .prefill-banner-text strong { display: block; margin-bottom: 2px; } + .prefill-banner-text small { opacity: 0.9; } + + .keypair-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 15px; + } + + .keypair-actions button { + flex: 1; + min-width: 150px; + } + + .identity-loaded { + background: #d4edda; + border: 1px solid #c3e6cb; + padding: 10px 15px; + border-radius: 4px; + margin-top: 15px; + display: flex; + align-items: center; + gap: 10px; + } + + .dark-theme .identity-loaded { + background: #1e3d1e; + border-color: #2e5d2e; + } + + .identity-loaded-icon { font-size: 1.5em; } + .identity-loaded-text { flex: 1; } + + .identicon-preview { + display: inline-block; + vertical-align: middle; + margin-left: 10px; + } + + .identicon-preview img { + width: 32px; + height: 32px; + image-rendering: pixelated; + border: 1px solid var(--border); + border-radius: 4px; + } + + .identicon-section { + background: var(--card-bg); + border: 1px solid var(--border); + border-left: 4px solid #9c27b0; + padding: 15px; + margin: 15px 0; + border-radius: 0 4px 4px 0; + text-align: center; + } + + .identicon-section h4 { + margin: 0 0 15px 0; + color: #9c27b0; + } + + .identicon-large-preview { + margin: 15px 0; + } + + .identicon-hash { + font-size: 0.8em; + color: #666; + word-break: break-all; + margin: 10px 0; + } + + .identicon-hash code { + background: var(--input-readonly); + padding: 4px 8px; + border-radius: 4px; + display: inline-block; + max-width: 100%; + overflow-x: auto; + } + + .identicon-actions { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; + margin: 15px 0; + } + + .identicon-actions button { + margin: 0; + padding: 8px 15px; + font-size: 0.9em; + } + + .identicon-tip { + background: #f3e5f5; + padding: 10px; + border-radius: 4px; + margin-top: 10px; + } + + .dark-theme .identicon-tip { + background: #2d1f30; + } + + footer { + text-align: center; + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid var(--border); + font-size: 0.9em; + color: #888; + } + + .footer-links { + display: flex; + justify-content: center; + gap: 20px; + margin-top: 10px; + } + + .footer-links a { + color: var(--primary); + text-decoration: none; + transition: color 0.2s; + } + + .footer-links a:hover { + color: var(--primary-hover); + text-decoration: underline; + } + + @media (max-width: 600px) { + .container { padding: 10px; } + .tab-content { padding: 15px 10px; } + .footer-links { flex-direction: column; gap: 10px; } + .gateway-item { flex-direction: column; align-items: flex-start; gap: 5px; } + .gateway-address { width: 100%; } + .notification { right: 10px; left: 10px; max-width: none; } + .keypair-actions { flex-direction: column; } + .keypair-actions button { min-width: 100%; } + } + </style> + <script src="/static/nacl.min.js"></script> + <script src="/static/nacl-util.min.js"></script> +</head> +<body> +<div class="container"> + <header> + <h1>N2Usenet Gateway v2.7 Nym</h1> + <div class="theme-toggle"> + <span>🌞</span> + <input type="checkbox" id="themeToggle"> + <label for="themeToggle" class="toggle"></label> + <span>🌙</span> + </div> + </header> + + <div id="prefillBanner" class="prefill-banner" style="display: none;"> + <span class="prefill-banner-icon">📝</span> + <div class="prefill-banner-text"> + <strong id="prefillTitle">New Post</strong> + <small id="prefillDetails">Fields pre-filled from Onion Newsreader</small> + </div> + </div> + + <div class="tabs"> + <button id="tabBtn1" onclick="showTab('pow')"> + <span>1. Generate Hashcash Token</span> + </button> + <button id="tabBtn2" onclick="showTab('sign')"> + <span>2. Sign Message (Identity)</span> + </button> + <button id="tabBtn3" onclick="showTab('send')"> + <span>3. Send Message</span> + </button> + </div> + + <div id="pow" class="tab-content active"> + <h2>1. Load Identity or Start Fresh</h2> + + <div class="identity-loader" style="background: var(--card-bg); border: 2px dashed var(--primary); border-radius: 8px; padding: 20px; margin-bottom: 20px; text-align: center;"> + <p style="margin: 0 0 15px 0;"><strong>🔑 Have a saved identity?</strong> Load it to auto-fill all fields.</p> + <button id="loadIdentityBtn" class="btn-primary" style="font-size: 1.1em; padding: 12px 30px;">📂 Load Identity File</button> + <input type="file" id="identityFileInput" accept=".json" style="display:none;"> + <p style="margin: 15px 0 0 0; font-size: 0.9em; color: var(--text); opacity: 0.7;">Or fill the fields below to create a new identity.</p> + </div> + + <div id="identityLoadedPow" class="identity-loaded" style="display:none; margin-bottom: 20px;"> + <span class="identity-loaded-icon">✅</span> + <div class="identity-loaded-text"> + <strong id="loadedIdentityName">Identity Loaded</strong> + <small id="loadedIdentityEmail">email@example.com</small> + </div> + <div class="identicon-preview"> + <img id="identiconPreviewPow" alt="Your identicon" style="width: 48px; height: 48px; image-rendering: pixelated; border-radius: 4px;"> + </div> + </div> + + <div class="section-info"> + <p><strong>About N2Usenet:</strong> A privacy-focused standalone gateway that posts to Usenet newsgroups over Nym to the TCPReset Mail2News and NNTP infrastructure. No access logs are kept. To post you must first create, or load, a <strong>VFACE pseudonymous identity</strong>: an Ed25519 keypair whose public key deterministically generates your identicon. Same key → same identicon → same person over time.</p> + <p><strong>What is Proof-of-Work?</strong> This step generates a hashcash token that prevents spam by requiring your computer to perform some calculations.</p> + </div> + + <label>Username (your pseudonym): <input type="text" id="fromName" placeholder="YourPseudonym" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></label> + <label>Email (for identity hash): <input type="email" id="hcEmail" placeholder="your@email.example" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></label> + <label>Difficulty (bits): + <select id="hcBits"> + <option value="16">16 bits (very fast, ~instant - recommended for hardened browsers)</option> + <option value="20" selected>20 bits (fast, ~few seconds)</option> + <option value="24">24 bits (medium, ~30-60 seconds)</option> + <option value="28">28 bits (slow, ~several minutes)</option> + </select> + </label> + <button id="genTokenBtn">Generate Token</button> + <div class="progress-bar"><div id="tokenProgress" class="progress-bar-inner">Ready</div></div> + <label>Generated Token:</label> + <div id="tokenOutput" class="output-field empty">Token will appear here after generation</div> + </div> + + <div id="sign" class="tab-content"> + <h2>2. Sign Your Message</h2> + + <div id="identiconSection" class="identicon-section" style="display:none;"> + <h4>🎨 Your Visual Identity</h4> + <div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;"> + <div class="identicon-large-preview"> + <img id="identiconPreviewLarge" alt="Your identicon" style="width:96px;height:96px;image-rendering:pixelated;border:2px solid var(--border);border-radius:8px;"> + </div> + <div style="flex: 1; min-width: 200px;"> + <p style="margin: 0 0 5px 0;"><strong id="identityUsername">Username</strong></p> + <p style="margin: 0 0 10px 0; font-size: 0.9em; color: var(--text); opacity: 0.8;" id="identityEmail">email@example.com</p> + <p class="identicon-hash" style="margin: 0;">Hash: <code id="identityHashDisplay"></code></p> + </div> + </div> + <p style="margin-top: 15px; text-align: center; font-size: 0.95em;">Identity material stays only in browser memory for this tab and is wiped after a successful send.</p> + <p style="margin-top: 15px; text-align: center; font-size: 0.9em;"> + <a href="https://identicons.virebent.art" target="_blank" rel="noopener noreferrer" style="color: #9c27b0; text-decoration: none;">🔐 VFACE Verifier - Verify an identity</a> + </p> + </div> + + <div class="section-info"> + <p><strong>What is this?</strong> Your keypair is generated and your message is signed <strong>locally in your browser</strong> with the self-hosted TweetNaCl (<code>nacl</code>) library, and your Ed25519 secret key never leaves your device. Only the public identity (username, email, public key) is sent, to render the identicon. The signature proves you authored this post.</p> + <p><strong>VFACE / Face Header:</strong> Your identicon is produced by the original <code>identicons-cli</code> engine, the same backend as <code>identicons.virebent.art</code>, so it is fully deterministic: the same key always yields the same identicon. It is embedded as a folded <code>Face:</code> header (RFC 4021/2822) alongside <code>X-Ed25519-Pub</code> and <code>X-Ed25519-Sig</code>, visible in newsreaders like Newsgrouper and re-verifiable by any reader from your public key.</p> + </div> + + <label>Email (from PoW):</label> + <input type="text" id="readonlyEmailSign" readonly> + + <label>Message to Sign:</label> + <textarea id="messageToSign" rows="6" placeholder="Write your message here..." autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></textarea> + + <div class="keypair-actions"> + <button id="genKeyBtn">🔑 Generate New Keypair</button> + </div> + <input type="file" id="keyFileInput" accept=".json" style="display:none;"> + + <button id="signMsgBtn" disabled>✍️ Sign Message</button> + + <label>Public Key:</label> + <div id="pubKeyOutput" class="output-field empty">Public key will appear here</div> + <label>Signature:</label> + <div id="signatureOutput" class="output-field empty">Signature will appear here</div> + </div> + + <div id="send" class="tab-content"> + <h2>Send Message</h2> + <div class="section-info"> + <p><strong>What is this?</strong> This final step sends your signed message to Usenet via Nym, Mail2News, and the <code>news.tcpreset.net</code> NNTP service.</p> + <div class="gateway-info"> + <h4>🔐 Nym to TCPReset Posting Path</h4> + <p style="margin-bottom: 15px; font-size: 0.95em;">Your message is submitted over HTTPS, then relayed from this server through Nym to the Mail2News gateway and finally into <code>news.tcpreset.net</code> NNTP:</p> + + <div class="gateway-item"> + <span class="gateway-priority">Step 1</span> + <div style="flex: 1;"> + <strong>Nym SMTP transport</strong> + <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Nym SOCKS5/requester transport to the TCPReset Mail2News SMTP gateway.</p> + </div> + </div> + <details style="margin-top: 8px; font-size: 0.8em;"> + <summary style="cursor: pointer; color: #007bff;">Show relay nodes</summary> + <ul style="margin: 8px 0; padding-left: 20px; font-family: monospace; color: #555;"> + <li><strong>SMTP:</strong> mail2news.tcpreset.net:587 STARTTLS via Nym SOCKS5</li> + </ul> + </details> + + <div class="gateway-item"> + <span class="gateway-priority">Step 2</span> + <div style="flex: 1;"> + <strong>Mail2News Gateway</strong> + <div class="gateway-address" style="margin-top: 5px;">mail2news@mail2news.tcpreset.net</div> + <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Converts the signed email payload into a Usenet post for the TCPReset news service.</p> + </div> + </div> + + <div class="gateway-item"> + <span class="gateway-priority">Step 3</span> + <div style="flex: 1;"> + <strong>TCPReset NNTP</strong> + <div class="gateway-address" style="margin-top: 5px;">nntp://news.tcpreset.net</div> + <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Final posting target for the standalone N2Usenet path.</p> + </div> + </div> + + <p style="margin-top: 15px; padding: 10px; background: rgba(0,123,255,0.1); border-radius: 4px; font-size: 0.9em;"> + <strong>🔒 Privacy Guarantee:</strong> The browser-to-server hop is HTTPS. The server-to-Mail2News hop uses Nym plus SMTP TLS. Mail2News then posts into <code>news.tcpreset.net</code> NNTP. + </p> + </div> + </div> + + <label>Email used for PoW:</label> + <input type="text" id="readonlyEmailSend" readonly> + <form id="sendForm" method="POST" action="/submit" autocomplete="off"> + <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> + <label>From (Name):</label> + <input type="text" id="readonlyFromName" readonly> + <input type="hidden" name="from" id="fromFull"> + <label>Newsgroups (max 3, comma separated): + <input type="text" name="newsgroups" id="newsgroups" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required placeholder="e.g. alt.privacy, comp.security"> + </label> + <label>Subject: <input type="text" name="subject" id="subject" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required placeholder="Message Subject"></label> + <label>References (optional): <input type="text" name="references" id="references" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="Message-ID of post you're replying to"></label> + <label>X-Hashcash Token: <input type="text" name="xhashcash" id="hcToken" required readonly></label> + <label>Message:</label> + <textarea name="message" id="messageContent" rows="8" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required placeholder="Your message will appear here after signing..."></textarea> + <input type="hidden" name="x-ed25519-pub" id="x-ed25519-pub"> + <input type="hidden" name="x-ed25519-sig" id="x-ed25519-sig"> + <button type="submit" id="sendBtn">Send Message</button> + </form> + </div> + + <footer> + <div>N2Usenet Gateway v2.7.0 © 2025 - Privacy-focused Usenet posting via Nym</div> + <div class="footer-links"> + <a href="https://yamn.virebent.art">Home</a> + <a href="mailto:%69%6E%66%6F%40%76%69%72%65%62%65%6E%74%2E%61%72%74">Contact</a> + <a href="https://github.com/gabrix73/m2usenet-go">Code</a> + </div> + </footer> +</div> + +<script> +// ============================================================================ +// GLOBAL FUNCTIONS - MUST BE DEFINED FIRST +// ============================================================================ + +function showTab(id) { + document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active')); + document.getElementById(id).classList.add('active'); +} + +function showNotification(message, type, duration) { + type = type || 'info'; + duration = duration === undefined ? 5000 : duration; + const icons = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' }; + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + notification.innerHTML = ` + <span class="notification-icon">${icons[type] || icons.info}</span> + <span class="notification-message">${message}</span> + <span class="notification-close" onclick="this.parentElement.remove()">×</span> + `; + document.body.appendChild(notification); + if (duration > 0) { + setTimeout(() => { if (notification.parentElement) notification.remove(); }, duration); + } +} + +// ============================================================================ +// STATE MANAGEMENT +// ============================================================================ + +const appState = { + step1Complete: false, + step2Complete: false, + step3Complete: false, + prefillMode: null +}; + +function updateTabIndicators() { + document.getElementById('tabBtn1').classList.toggle('completed', appState.step1Complete); + document.getElementById('tabBtn2').classList.toggle('completed', appState.step2Complete); + document.getElementById('tabBtn3').classList.toggle('completed', appState.step3Complete); +} + +let keyPair = null; +let workersSupported = true; + +function setOutputField(id, value, emptyText) { + const el = document.getElementById(id); + if (!el) return; + if (value) { + el.innerText = value; + el.classList.remove('empty'); + return; + } + el.innerText = emptyText; + el.classList.add('empty'); +} + +function resetEphemeralState(messageID) { + if (keyPair) { + if (keyPair.secretKey && typeof keyPair.secretKey.fill === 'function') keyPair.secretKey.fill(0); + if (keyPair.publicKey && typeof keyPair.publicKey.fill === 'function') keyPair.publicKey.fill(0); + } + keyPair = null; + currentIdenticonData = null; + + appState.step1Complete = false; + appState.step2Complete = false; + appState.step3Complete = false; + updateTabIndicators(); + + [ + 'fromName', 'hcEmail', 'readonlyEmailSign', 'readonlyEmailSend', + 'readonlyFromName', 'fromFull', 'newsgroups', 'subject', 'references', + 'hcToken', 'messageToSign', 'messageContent', 'x-ed25519-pub', + 'x-ed25519-sig' + ].forEach((id) => { + const el = document.getElementById(id); + if (el) el.value = ''; + }); + + ['identityFileInput', 'keyFileInput'].forEach((id) => { + const el = document.getElementById(id); + if (el) el.value = ''; + }); + + document.querySelectorAll('input, textarea').forEach((el) => { + el.classList.remove('error', 'prefilled'); + }); + + const hcBits = document.getElementById('hcBits'); + if (hcBits) hcBits.value = '20'; + + setOutputField('tokenOutput', '', 'Token will appear here after generation'); + setOutputField('pubKeyOutput', '', 'Public key will appear here'); + setOutputField('signatureOutput', '', 'Signature will appear here'); + + const tokenProgress = document.getElementById('tokenProgress'); + if (tokenProgress) { + tokenProgress.style.width = '0'; + tokenProgress.textContent = 'Ready'; + } + + const identityLoaded = document.getElementById('identityLoadedPow'); + if (identityLoaded) identityLoaded.style.display = 'none'; + const identiconSection = document.getElementById('identiconSection'); + if (identiconSection) identiconSection.style.display = 'none'; + const prefillBanner = document.getElementById('prefillBanner'); + if (prefillBanner) { + prefillBanner.style.display = 'none'; + prefillBanner.classList.remove('reply'); + } + + ['identiconPreviewPow', 'identiconPreviewLarge'].forEach((id) => { + const img = document.getElementById(id); + if (img) img.removeAttribute('src'); + }); + const identityHash = document.getElementById('identityHashDisplay'); + if (identityHash) identityHash.textContent = ''; + const loadedName = document.getElementById('loadedIdentityName'); + if (loadedName) loadedName.textContent = 'Identity Loaded'; + const loadedEmail = document.getElementById('loadedIdentityEmail'); + if (loadedEmail) loadedEmail.textContent = 'Identity kept only in memory'; + const identityUsername = document.getElementById('identityUsername'); + if (identityUsername) identityUsername.textContent = 'Username'; + const identityEmail = document.getElementById('identityEmail'); + if (identityEmail) identityEmail.textContent = 'email@example.com'; + + const genKeyBtn = document.getElementById('genKeyBtn'); + if (genKeyBtn) { + genKeyBtn.disabled = false; + genKeyBtn.textContent = '🔑 Generate New Keypair'; + } + const signBtn = document.getElementById('signMsgBtn'); + if (signBtn) signBtn.disabled = true; + const sendBtn = document.getElementById('sendBtn'); + if (sendBtn) { + sendBtn.disabled = false; + sendBtn.textContent = 'Send Message'; + sendBtn.style.opacity = ''; + sendBtn.style.cursor = ''; + } + const progressDiv = document.getElementById('sending-progress'); + if (progressDiv) progressDiv.remove(); + + if (window.history && window.history.replaceState) { + window.history.replaceState({}, document.title, window.location.pathname); + } + + showTab('pow'); + + if (messageID) { + showNotification(`✓ Message sent. Message-ID: ${messageID}. Identity and form data cleared from memory.`, 'success', 12000); + } +} + +try { + if (typeof Worker === 'undefined') { + workersSupported = false; + } +} catch (e) { + workersSupported = false; +} + +// ============================================================================ +// THEME TOGGLE +// ============================================================================ + +document.getElementById('themeToggle').addEventListener('change', function() { + document.body.classList.toggle('dark-theme', this.checked); +}); + +// ============================================================================ +// IDENTICON GENERATION (client-side) +// ============================================================================ + +let currentIdenticonData = null; + +async function generateIdenticon(username, email, pubkeyBase64) { + // Generate the identicon with the ORIGINAL identicons-cli engine (server-side), + // the same backend used by identicons.virebent.art (Ch1ffr3punk algorithm). + // Only PUBLIC data leaves the browser (username|email|ed25519-pubkey). + const body = new URLSearchParams({ username: username, email: email, pubkey: pubkeyBase64 }); + const resp = await fetch('identicon.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString() + }); + if (!resp.ok) throw new Error('identicon engine error ' + resp.status); + const r = await resp.json(); + if (!r.face48 || !r.preview) throw new Error('identicon engine returned no data'); + + currentIdenticonData = { + dataUrl: r.preview, // 256x256 PNG dataurl (display/download) + hash: r.hash, // sha256(username|email|pubkey) + face48: r.face48, // raw base64 PNG (48x48) -> Face: header + faceHeader: r.faceHeader, // folded "Face: ..." header (RFC 2822) + username: username, + email: email, + pubkey: pubkeyBase64, + claim: JSON.stringify({ + version: 2, + type: 'n2usenet-identity', + username: username, + email: email, + pubkey: pubkeyBase64, + identityHash: r.hash, + created: new Date().toISOString(), + algorithm: 'identicons-cli sha256(username|email|pubkey) [VFACE]' + }, null, 2) + }; + return r.preview; +} + +async function updateIdenticonPreview() { + if (!keyPair) return; + + const pubB64 = nacl.util.encodeBase64(keyPair.publicKey); + const username = document.getElementById('fromName').value || 'Anonymous'; + const email = document.getElementById('hcEmail').value || 'anonymous@n2usenet.local'; + + try { + const identiconUrl = await generateIdenticon(username, email, pubB64); + + document.getElementById('identiconPreviewPow').src = identiconUrl; + document.getElementById('identiconPreviewLarge').src = identiconUrl; + document.getElementById('identityHashDisplay').textContent = currentIdenticonData.hash; + document.getElementById('identityUsername').textContent = username; + document.getElementById('identityEmail').textContent = email; + document.getElementById('identiconSection').style.display = 'block'; + + } catch (e) { + console.error('Identicon generation failed:', e); + } +} + +// ============================================================================ +// INITIALIZATION +// ============================================================================ + +document.addEventListener('DOMContentLoaded', function() { + if (typeof nacl === 'undefined') { + showNotification('Cryptography library failed to load. Please refresh the page.', 'error', 0); + return; + } + + if (typeof nacl.util === 'undefined') { + nacl.util = { + decodeUTF8: function(str) { return new TextEncoder().encode(str); }, + encodeUTF8: function(arr) { return new TextDecoder().decode(arr); }, + encodeBase64: function(arr) { + return btoa(Array.from(new Uint8Array(arr)).map(byte => String.fromCharCode(byte)).join('')); + }, + decodeBase64: function(b64) { + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; + } + }; + } + + showTab('pow'); + showNotification('N2Usenet Gateway v2.7.0 ready. No identity or message data is persisted.', 'success', 4000); +}); + +// ============================================================================ +// SHA-1 IMPLEMENTATION +// ============================================================================ + +function sha1(str) { + function rotate_left(n, s) { return (n << s) | (n >>> (32 - s)); } + function cvt_hex(val) { + let str = ''; + for (let i = 7; i >= 0; i--) { + const v = (val >>> (i * 4)) & 0x0f; + str += v.toString(16); + } + return str; + } + function utf8Encode(str) { return unescape(encodeURIComponent(str)); } + + let blockstart, i, j; + const W = new Array(80); + let H0 = 0x67452301, H1 = 0xEFCDAB89, H2 = 0x98BADCFE, H3 = 0x10325476, H4 = 0xC3D2E1F0; + let A, B, C, D, E, temp; + + str = utf8Encode(str); + const str_len = str.length; + const word_array = []; + + for (i = 0; i < str_len - 3; i += 4) { + j = str.charCodeAt(i) << 24 | str.charCodeAt(i + 1) << 16 | str.charCodeAt(i + 2) << 8 | str.charCodeAt(i + 3); + word_array.push(j); + } + + switch (str_len % 4) { + case 0: i = 0x080000000; break; + case 1: i = str.charCodeAt(str_len - 1) << 24 | 0x0800000; break; + case 2: i = str.charCodeAt(str_len - 2) << 24 | str.charCodeAt(str_len - 1) << 16 | 0x08000; break; + case 3: i = str.charCodeAt(str_len - 3) << 24 | str.charCodeAt(str_len - 2) << 16 | str.charCodeAt(str_len - 1) << 8 | 0x80; break; + } + + word_array.push(i); + while ((word_array.length % 16) != 14) word_array.push(0); + word_array.push(str_len >>> 29); + word_array.push((str_len << 3) & 0x0ffffffff); + + for (blockstart = 0; blockstart < word_array.length; blockstart += 16) { + for (i = 0; i < 16; i++) W[i] = word_array[blockstart + i]; + for (i = 16; i <= 79; i++) W[i] = rotate_left(W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16], 1); + + A = H0; B = H1; C = H2; D = H3; E = H4; + + for (i = 0; i <= 19; i++) { + temp = (rotate_left(A, 5) + ((B & C) | (~B & D)) + E + W[i] + 0x5A827999) & 0x0ffffffff; + E = D; D = C; C = rotate_left(B, 30); B = A; A = temp; + } + for (i = 20; i <= 39; i++) { + temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0x6ED9EBA1) & 0x0ffffffff; + E = D; D = C; C = rotate_left(B, 30); B = A; A = temp; + } + for (i = 40; i <= 59; i++) { + temp = (rotate_left(A, 5) + ((B & C) | (B & D) | (C & D)) + E + W[i] + 0x8F1BBCDC) & 0x0ffffffff; + E = D; D = C; C = rotate_left(B, 30); B = A; A = temp; + } + for (i = 60; i <= 79; i++) { + temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0xCA62C1D6) & 0x0ffffffff; + E = D; D = C; C = rotate_left(B, 30); B = A; A = temp; + } + + H0 = (H0 + A) & 0x0ffffffff; + H1 = (H1 + B) & 0x0ffffffff; + H2 = (H2 + C) & 0x0ffffffff; + H3 = (H3 + D) & 0x0ffffffff; + H4 = (H4 + E) & 0x0ffffffff; + } + + return cvt_hex(H0) + cvt_hex(H1) + cvt_hex(H2) + cvt_hex(H3) + cvt_hex(H4); +} + +function mineSingleThread(prefix, targetZeros, progressCallback, foundCallback) { + const target = '0'.repeat(targetZeros); + let nonce = 0, checked = 0; + const batchSize = 100; + + function mineNextBatch() { + const endNonce = nonce + batchSize; + while (nonce < endNonce) { + const token = prefix + nonce; + const hash = sha1(token); + checked++; + if (hash.startsWith(target)) { + foundCallback(nonce, checked); + return; + } + nonce++; + } + progressCallback(checked); + setTimeout(mineNextBatch, 0); + } + mineNextBatch(); +} + +// ============================================================================ +// STEP 1: HASHCASH TOKEN GENERATION +// ============================================================================ + +document.getElementById('genTokenBtn').onclick = () => { + const email = document.getElementById('hcEmail').value.trim(); + const bits = parseInt(document.getElementById('hcBits').value); + + if (!email) { + showNotification('⚠ Please enter your email address first!', 'warning'); + document.getElementById('hcEmail').classList.add('error'); + document.getElementById('hcEmail').focus(); + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + showNotification('⚠ Please enter a valid email address!', 'warning'); + document.getElementById('hcEmail').classList.add('error'); + document.getElementById('hcEmail').focus(); + return; + } + + document.getElementById('hcEmail').classList.remove('error'); + showNotification(`🔨 Starting token generation (${bits} bits)...`, 'info'); + + const btn = document.getElementById('genTokenBtn'); + btn.disabled = true; + btn.textContent = 'Mining Token...'; + + const now = new Date(); + const timestamp = now.getUTCFullYear().toString().slice(2) + + ("0"+(now.getUTCMonth()+1)).slice(-2) + + ("0"+now.getUTCDate()).slice(-2) + + ("0"+now.getUTCHours()).slice(-2) + + ("0"+now.getUTCMinutes()).slice(-2) + + ("0"+now.getUTCSeconds()).slice(-2); + const ext = ""; + const rand = Math.floor(Math.random()*1e6); + const prefix = `1:${bits}:${timestamp}:${email}:${ext}:${rand}:`; + const zeros = bits/4; + + const progBar = document.getElementById('tokenProgress'); + progBar.style.width = '0%'; + progBar.innerText = 'Mining...'; + + let totalChecked = 0; + let startTime = Date.now(); + + function updateProgress(checked) { + totalChecked += checked; + const elapsed = (Date.now() - startTime) / 1000; + const hashRate = Math.round(totalChecked / elapsed); + progBar.style.width = Math.min(95, (totalChecked / 10000)) + '%'; + progBar.innerText = `${totalChecked} hashes (${hashRate}/s)`; + } + + function onFound(nonce, totalHashes) { + const token = prefix + nonce; + const outputField = document.getElementById('tokenOutput'); + outputField.innerText = token; + outputField.classList.remove('empty'); + document.getElementById('hcToken').value = token; + + const fromName = document.getElementById('fromName').value || 'Anonymous'; + document.getElementById('fromFull').value = `${fromName} <${email}>`; + document.getElementById('readonlyFromName').value = fromName; + document.getElementById('readonlyEmailSign').value = email; + document.getElementById('readonlyEmailSend').value = email; + + btn.disabled = false; + btn.textContent = 'Generate Token'; + progBar.style.width = '100%'; + progBar.innerText = `Complete! (${totalHashes} hashes)`; + + appState.step1Complete = true; + updateTabIndicators(); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + showNotification(`✓ Token generated in ${elapsed}s!`, 'success', 8000); + + setTimeout(() => showTab('sign'), 2000); + } + + if (workersSupported) { + try { + const cores = navigator.hardwareConcurrency || 2; + let found = false; + let coreChecked = Array(cores).fill(0); + const workers = []; + + function updateCombinedProgress() { + const sum = coreChecked.reduce((a,b)=>a+b,0); + updateProgress(sum - totalChecked); + } + + for (let i = 0; i < cores; i++) { + try { + const w = new Worker('/static/powWorker.js'); + w.postMessage({ prefix, targetZeros: zeros, startNonce: i, step: cores }); + + w.onmessage = e => { + if (found) return; + if (e.data.type === 'found') { + found = true; + workers.forEach(x => x.terminate()); + onFound(e.data.nonce, e.data.checked); + } + if (e.data.type === 'progress') { + coreChecked[i] = e.data.checked; + updateCombinedProgress(); + } + }; + + w.onerror = (err) => { + if (i === 0) { + workers.forEach(x => x.terminate()); + mineSingleThread(prefix, zeros, updateProgress, onFound); + } + }; + + workers.push(w); + } catch (err) { + if (i === 0) { + mineSingleThread(prefix, zeros, updateProgress, onFound); + return; + } + } + } + + } catch (err) { + mineSingleThread(prefix, zeros, updateProgress, onFound); + } + } else { + mineSingleThread(prefix, zeros, updateProgress, onFound); + } +}; + +// ============================================================================ +// STEP 2: ED25519 SIGNATURE + KEYPAIR MANAGEMENT +// ============================================================================ + +document.getElementById('genKeyBtn').onclick = async function() { + try { + showNotification('🔐 Generating Ed25519 key pair...', 'info'); + keyPair = nacl.sign.keyPair(); + + const pubB64 = nacl.util.encodeBase64(keyPair.publicKey); + + const pubOutput = document.getElementById('pubKeyOutput'); + pubOutput.innerText = pubB64; + pubOutput.classList.remove('empty'); + document.getElementById('x-ed25519-pub').value = pubB64; + document.getElementById('signMsgBtn').disabled = false; + + const username = document.getElementById('fromName').value || 'Anonymous'; + const email = document.getElementById('hcEmail').value || ''; + document.getElementById('identityLoadedPow').style.display = 'flex'; + document.getElementById('loadedIdentityName').textContent = username; + document.getElementById('loadedIdentityEmail').textContent = email || 'Identity active only in memory'; + + await updateIdenticonPreview(); + showNotification('✓ Identity generated in memory. Now write and sign your message.', 'success', 6000); + } catch (error) { + showNotification('✗ Error: ' + error.message, 'error'); + } +}; + +document.getElementById('loadIdentityBtn').onclick = function() { + document.getElementById('identityFileInput').click(); +}; + +document.getElementById('identityFileInput').onchange = async function(e) { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + const data = JSON.parse(text); + + if (!data.publicKey || !data.secretKey) { + throw new Error('Invalid identity file format'); + } + + const pubKey = nacl.util.decodeBase64(data.publicKey); + const secKey = nacl.util.decodeBase64(data.secretKey); + + if (pubKey.length !== 32 || secKey.length !== 64) { + throw new Error('Invalid key lengths'); + } + + keyPair = { publicKey: pubKey, secretKey: secKey }; + + const pubB64 = data.publicKey; + const pubOutput = document.getElementById('pubKeyOutput'); + pubOutput.innerText = pubB64; + pubOutput.classList.remove('empty'); + document.getElementById('x-ed25519-pub').value = pubB64; + document.getElementById('signMsgBtn').disabled = false; + + if (data.username) { + document.getElementById('fromName').value = data.username; + document.getElementById('readonlyFromName').value = data.username; + } + if (data.email) { + document.getElementById('hcEmail').value = data.email; + const fromName = document.getElementById('fromName').value || 'Anonymous'; + document.getElementById('fromFull').value = `${fromName} <${data.email}>`; + document.getElementById('readonlyEmailSign').value = data.email; + document.getElementById('readonlyEmailSend').value = data.email; + } + + const username = data.username || 'Anonymous'; + const email = data.email || ''; + document.getElementById('identityLoadedPow').style.display = 'flex'; + document.getElementById('loadedIdentityName').textContent = username; + document.getElementById('loadedIdentityEmail').textContent = email; + + await updateIdenticonPreview(); + + if (currentIdenticonData && currentIdenticonData.dataUrl) { + document.getElementById('identiconPreviewPow').src = currentIdenticonData.dataUrl; + } + + document.getElementById('genKeyBtn').textContent = '🔑 Replace Keypair'; + showNotification('✓ Identity loaded into memory only. Now generate PoW token.', 'success', 6000); + } catch (error) { + showNotification('✗ Failed to load identity: ' + error.message, 'error'); + } + + e.target.value = ''; +}; + +// Usenet line-wrap convention (RFC 1855 Netiquette Guidelines): wrap plain-text +// body lines at ~72 columns for compatibility with old-style newsreaders. +// Must run BEFORE signing, not after, so the Ed25519 signature covers the +// exact bytes that end up posted to Usenet. +function wrapUsenetText(text, width) { + width = width || 72; + return text.split('\n').map(function(line) { + if (line.length === 0 || line.startsWith('>')) return line; + const words = line.split(' '); + const wrapped = []; + let current = ''; + for (const word of words) { + if (current === '') { + current = word; + } else if ((current + ' ' + word).length <= width) { + current += ' ' + word; + } else { + wrapped.push(current); + current = word; + } + } + wrapped.push(current); + return wrapped.join('\n'); + }).join('\n'); +} + +document.getElementById('signMsgBtn').onclick = function() { + try { + if (!keyPair) { + showNotification('⚠ Please generate or load a key pair first!', 'warning'); + return; + } + + const msg = wrapUsenetText(document.getElementById('messageToSign').value.trim()); + if (!msg) { + showNotification('⚠ Please write a message!', 'warning'); + document.getElementById('messageToSign').classList.add('error'); + document.getElementById('messageToSign').focus(); + return; + } + + if (msg.length < 10) { + showNotification('⚠ Message too short (min 10 characters)', 'warning'); + return; + } + + document.getElementById('messageToSign').classList.remove('error'); + showNotification('✍ Signing message...', 'info'); + + const msgBytes = nacl.util.decodeUTF8(msg); + const sig = nacl.sign.detached(msgBytes, keyPair.secretKey); + const sigB64 = nacl.util.encodeBase64(sig); + + const sigOutput = document.getElementById('signatureOutput'); + sigOutput.innerText = sigB64; + sigOutput.classList.remove('empty'); + document.getElementById('x-ed25519-sig').value = sigB64; + + document.getElementById('messageContent').value = msg + "\n\n--- Digital Signature ---\n" + sigB64; + + appState.step2Complete = true; + updateTabIndicators(); + showNotification('✓ Message signed with your in-memory identity.', 'success', 8000); + setTimeout(() => showTab('send'), 1200); + } catch (error) { + showNotification('✗ Error: ' + error.message, 'error'); + } +}; + +document.getElementById('fromName').addEventListener('input', function() { + const email = document.getElementById('hcEmail').value; + document.getElementById('readonlyFromName').value = this.value; + if (email) { + document.getElementById('fromFull').value = `${this.value} <${email}>`; + } + if (keyPair) { + updateIdenticonPreview(); + } +}); + +// ============================================================================ +// STEP 3: SEND FORM +// ============================================================================ + +document.getElementById('sendForm').addEventListener('submit', async function(e) { + e.preventDefault(); + + const requiredFields = [ + {id: 'fromFull', name: 'From'}, + {id: 'newsgroups', name: 'Newsgroups'}, + {id: 'subject', name: 'Subject'}, + {id: 'hcToken', name: 'Hashcash Token'}, + {id: 'messageContent', name: 'Message'}, + {id: 'x-ed25519-pub', name: 'Public Key'}, + {id: 'x-ed25519-sig', name: 'Signature'} + ]; + + const missing = []; + requiredFields.forEach(field => { + const el = document.getElementById(field.id); + if (!el.value.trim()) { + missing.push(field.name); + el.classList.add('error'); + } else { + el.classList.remove('error'); + } + }); + + if (missing.length > 0) { + showNotification(`⚠ Missing: ${missing.join(', ')}`, 'warning', 8000); + return; + } + + const messageLen = document.getElementById('messageContent').value.length; + if (messageLen < 10) { + showNotification('⚠ Message too short', 'warning'); + return; + } + + const newsgroups = document.getElementById('newsgroups').value.split(',').map(g => g.trim()).filter(g => g); + if (newsgroups.length === 0) { + showNotification('⚠ Enter at least one newsgroup!', 'warning'); + return; + } + + if (newsgroups.length > 3) { + showNotification('⚠ Max 3 newsgroups!', 'warning'); + return; + } + + const confirmMsg = `Send message?\n\nNewsgroups: ${newsgroups.join(', ')}\nSubject: ${document.getElementById('subject').value}\nLength: ${messageLen} chars\n\nDelivery via Nym may take up to 120 seconds.`; + + if (!confirm(confirmMsg)) { + return; + } + + showNotification('📤 Sending message...', 'info', 0); + const sendBtn = document.getElementById('sendBtn'); + sendBtn.disabled = true; + sendBtn.textContent = 'Sending...'; + sendBtn.style.opacity = '0.6'; + sendBtn.style.cursor = 'not-allowed'; + + const progressDiv = document.createElement('div'); + progressDiv.id = 'sending-progress'; + progressDiv.style.cssText = 'margin-top: 10px; padding: 10px; background: #fff3cd; border-left: 4px solid #ffc107; color: #856404;'; + progressDiv.innerHTML = '<strong>⏳ Sending message...</strong><br>This may take up to 120 seconds via Nym. Please wait.'; + sendBtn.parentNode.insertBefore(progressDiv, sendBtn.nextSibling); + + try { + const resp = await fetch('/submit', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' + }, + body: new URLSearchParams(new FormData(this)).toString(), + credentials: 'same-origin', + cache: 'no-store' + }); + let data = {}; + try { + data = await resp.json(); + } catch (err) {} + if (!resp.ok) { + throw new Error(data.error || 'Delivery failed.'); + } + appState.step3Complete = true; + updateTabIndicators(); + resetEphemeralState(data.messageId || ''); + } catch (error) { + if (progressDiv.parentNode) progressDiv.remove(); + sendBtn.disabled = false; + sendBtn.textContent = 'Send Message'; + sendBtn.style.opacity = ''; + sendBtn.style.cursor = ''; + showNotification('✗ ' + error.message, 'error', 10000); + } +}); + +// ============================================================================ +// ONION-NEWSREADER INTEGRATION +// ============================================================================ + +(function() { + function decode(s) { + if (!s) return ''; + try { + return decodeURIComponent(s.replace(/\+/g, ' ')); + } catch(e) { + return s; + } + } + + function fillField(id, value, highlight) { + highlight = highlight !== false; + const field = document.getElementById(id); + if (field && value) { + field.value = value; + if (highlight) field.classList.add('prefilled'); + return true; + } + return false; + } + + function showPrefillBanner(isReply, details) { + const banner = document.getElementById('prefillBanner'); + const title = document.getElementById('prefillTitle'); + const detailsEl = document.getElementById('prefillDetails'); + + if (banner) { + banner.style.display = 'flex'; + if (isReply) { + banner.classList.add('reply'); + title.textContent = '💬 Reply Mode'; + } else { + title.textContent = '📝 New Post'; + } + detailsEl.textContent = details; + } + } + + let prefillData = null; + let prefillSource = null; + const params = new URLSearchParams(window.location.search); + const action = params.get('action'); + + if (action === 'new' || action === 'reply' || params.has('newsgroups')) { + prefillData = { + newsgroups: decode(params.get('newsgroups') || ''), + subject: decode(params.get('subject') || ''), + references: decode(params.get('references') || ''), + body: decode(params.get('body') || '') + }; + prefillSource = 'URL'; + } + + if (prefillData) { + let filledFields = []; + + if (prefillData.newsgroups) { + fillField('newsgroups', prefillData.newsgroups); + filledFields.push('Newsgroups'); + } + + if (prefillData.subject) { + fillField('subject', prefillData.subject); + filledFields.push('Subject'); + } + + if (prefillData.references) { + fillField('references', prefillData.references); + filledFields.push('References'); + } + + if (prefillData.body) { + fillField('messageToSign', prefillData.body); + filledFields.push('Quoted message'); + } + + const isReply = !!(prefillData.references || + (prefillData.subject && prefillData.subject.toLowerCase().startsWith('re:'))); + + if (filledFields.length > 0) { + const details = `Pre-filled: ${filledFields.join(', ')} (from ${prefillSource})`; + showPrefillBanner(isReply, details); + + if (isReply) { + showNotification(`💬 Reply mode: ${prefillData.newsgroups}`, 'info', 5000); + } else { + showNotification(`📝 New post to: ${prefillData.newsgroups}`, 'success', 5000); + } + } + + setTimeout(() => showTab('pow'), 300); + } +})(); +</script> + +</body> +</html> 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 <user@example.invalid>\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:<n2usenet@virebent.art>\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, "<!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 "" +} |
