nx_opaque_login_routes.nx source
↩ module page · 187 lines · 17415 B
1// nx_opaque_login_routes.nx -- the SHARED route table for the no-cookie OPAQUE login. Builds a full HTTP
2// response into a caller buffer (NOT a socket write), so BOTH transports reuse one table (rule 15 DRY):
3// - plain HTTP daemon: sys_read -> olg_route -> sys_write
4// - TLS daemon: tls handshake -> app_recv -> olg_route -> app_send
5// Routes: GET /login (page) | POST /login | GET /whoami | GET /audio (session-gated) | POST /register.
6// No shortcuts: full OPAQUE, every reject path returns a clean status, no token leak on failure.
7import "nx_opaque_login.nx" // olg_ctx_setup / olg_register / olg_login / olg_whoami + NxAuthContext + NX_MAUTH_*
8import "nx_http_form.nx" // nx_http_form_get_field
9import "nx_audio_web.nx" // nx_audio_web_page -- the protected sovereign-audio page
10import "nx_finance_web.nx" // fw_render -- the protected sovereign zero-JS finance dashboard (nishifamily.com/finance)
11import "nx_intake_form.nx" // if_render -- the zero-JS intake form (GET /finance/intake)
12import "nx_intake_handler.nx" // ih_record -- parse the submitted form -> sv_record (POST /finance/intake)
13import "nx_lan_signup.nx" // ls_signup_allowed -- LAN-AND-INVITED signup predicate (lifted to runtime/ 2026-07-31)
14import "nx_bills_entry.nx" // be_record -- parse a submitted bill -> bs_add (POST /finance/bills)
15const OLR_MAGIC_2026: i64 = 2026
16const OLR_MAGIC_131072: i64 = 131072
17
18func olr_slen(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} return n }
19func olr_find(buf: *u8, n: i64, needle: *u8, nl: i64) -> i64 {
20 if nl==0 { return 0 }
21 var i: i64=0
22 while i+nl<=n { var j: i64=0; var ok: i64=1; while j<nl { if buf[i+j]!=needle[j]{ok=0; j=nl} else {j=j+1} } if ok==1 {return i} i=i+1 }
23 return 0-1
24}
25func olr_starts(buf: *u8, n: i64, pre: *u8) -> i64 { var i: i64=0; while pre[i]!=(0 as u8){ if i>=n {return 0} if buf[i]!=pre[i]{return 0} i=i+1 } return 1 }
26func olr_cat(dst: *u8, off: i64, s: *u8) -> i64 { var o: i64=off; var i: i64=0; while s[i]!=(0 as u8){dst[o]=s[i]; o=o+1; i=i+1} return o }
27func olr_catb(dst: *u8, off: i64, src: *u8, n: i64) -> i64 { var o: i64=off; var i: i64=0; while i<n {dst[o]=src[i]; o=o+1; i=i+1} return o }
28func olr_itoa(dst: *u8, off: i64, v: i64) -> i64 { let t: *u8=sys_mmap(28); var m: i64=v; var k: i64=0; if m==0{t[0]=48 as u8;k=1} while m>0{t[k]=(48+(m%10)) as u8;m=m/10;k=k+1} var o: i64=off; var q: i64=k-1; while q>=0{dst[o]=t[q];o=o+1;q=q-1} return o }
29func olr_hex(dst: *u8, off: i64, src: *u8, n: i64) -> i64 { let hx: *u8="0123456789abcdef" as *u8; var o: i64=off; var i: i64=0; while i<n { let c: i64=(src[i] as i64)&0xff; dst[o]=hx[(c>>4)&15]; dst[o+1]=hx[c&15]; o=o+2; i=i+1 } return o }
30func olr_hdr_val(req: *u8, hend: i64, name: *u8, nl: i64, out: *u8, cap: i64) -> i64 {
31 let p: i64 = olr_find(req, hend, name, nl)
32 if p < 0 { return 0 }
33 var i: i64 = p + nl
34 if i < hend { if req[i]==(32 as u8) { i=i+1 } }
35 var o: i64 = 0
36 while i < hend { let c: u8 = req[i]; if c==(13 as u8){i=hend} else { if c==(10 as u8){i=hend} else { if o<cap-1 {out[o]=c; o=o+1} i=i+1 } } }
37 out[o]=0 as u8; return o
38}
39// build a full HTTP/1.1 response into buf; return total length. status="200 OK", ctype="application/json".
40func olr_resp(buf: *u8, status: *u8, ctype: *u8, body: *u8, blen: i64) -> i64 {
41 var o: i64 = 0
42 o = olr_cat(buf, o, "HTTP/1.1 " as *u8); o = olr_cat(buf, o, status)
43 o = olr_cat(buf, o, "\r\nContent-Type: " as *u8); o = olr_cat(buf, o, ctype)
44 o = olr_cat(buf, o, "\r\nContent-Length: " as *u8); o = olr_itoa(buf, o, blen)
45 o = olr_cat(buf, o, "\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n" as *u8)
46 o = olr_catb(buf, o, body, blen)
47 return o
48}
49
50// 303 See Other -> turn a POST into a GET (post-redirect-get): refreshing the result won't re-submit.
51func olr_resp303(buf: *u8, location: *u8) -> i64 {
52 var o: i64 = 0
53 o = olr_cat(buf, o, "HTTP/1.1 303 See Other\r\nLocation: " as *u8); o = olr_cat(buf, o, location)
54 o = olr_cat(buf, o, "\r\nContent-Length: 0\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n" as *u8)
55 return o
56}
57
58const OLR_HTML: *u8 = "<!doctype html><html><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\"><title>Nishi OPAQUE login</title><style>body{font-family:system-ui,sans-serif;max-width:480px;margin:5vh auto;padding:0 18px;color:#16202e}h1{font-size:1.2rem}h3{margin:18px 0 4px}input{width:100%;padding:9px;margin:5px 0;box-sizing:border-box;border:1px solid #b9c2d6;border-radius:5px}button{padding:9px 16px;margin:6px 6px 6px 0;background:#0b2545;color:#fff;border:0;border-radius:5px;cursor:pointer;font-size:.95rem}#msg{margin:14px 0;padding:12px;background:#f1f4fa;border-left:3px solid #0b2545;word-break:break-all;min-height:1.2em}</style></head><body><h1>Nishi — no-cookie OPAQUE login</h1><p style=\"color:#667;font-size:.9rem\">Full OPAQUE aPAKE (RFC 9807) over TLS. The server never stores your passphrase; sessions are Ed25519 no-cookie tokens (sessionStorage + X-Nishi-Session header), never cookies.</p><div id=msg>Register a handle + passphrase, then log in.</div><h3>1. Register</h3><input id=rh placeholder=\"handle (e.g. elder)\"><input id=rp type=password placeholder=\"passphrase\"><button onclick=reg()>Register</button><h3>2. Login</h3><input id=lh placeholder=handle><input id=lp type=password placeholder=passphrase><button onclick=login()>Login</button> <button onclick=who()>Who am I?</button> <button onclick=aud()>Open audio</button> <button onclick=out()>Sign out</button><script>function $(i){return document.getElementById(i)}function M(t){$('msg').textContent=t}async function G(p,b){try{var o={method:b?'POST':'GET',headers:{}};if(b){o.headers['Content-Type']='application/x-www-form-urlencoded';o.body=b}else{o.headers['X-Nishi-Session']=sessionStorage.getItem('nsess')||''}var r=await fetch(p,o);var j={};try{j=await r.json()}catch(e){}return{ok:r.ok,s:r.status,j:j}}catch(e){return{ok:false,s:0,j:{error:'network: '+e}}}}async function reg(){M('Registering\\u2026 (memory-hard OPAQUE step \\u2014 a moment)');var r=await G('/register','handle='+encodeURIComponent($('rh').value)+'&pw='+encodeURIComponent($('rp').value));M(r.ok?('Registered! SAVE THIS RECOVERY MNEMONIC: '+r.j.mnemonic):('Register failed ('+r.s+'): '+(r.j.error||'unknown')))}async function login(){M('Logging in\\u2026 (a moment)');var r=await G('/login','handle='+encodeURIComponent($('lh').value)+'&pw='+encodeURIComponent($('lp').value));if(r.ok){sessionStorage.setItem('nsess',r.j.token);var R=new URLSearchParams(location.search).get('return');if(!R&&document.referrer){try{var u=new URL(document.referrer);if(u.origin==location.origin&&u.pathname!='/login'&&u.pathname!='/'){R=u.pathname}}catch(e){}}if(R){location.href=R;return}M('Logged in \\u2014 NO cookie. Token saved ('+r.j.token.length+' chars). Open /torrent or /gallery.')}else{M('Login failed ('+r.s+'): '+(r.j.error||'unknown'))}}async function who(){if(!sessionStorage.getItem('nsess')){M('No session \\u2014 log in first.');return}M('Validating\\u2026');var r=await G('/whoami');M(r.ok?('Valid no-cookie session. user-id hash: '+r.j.uid):('Session rejected ('+r.s+'): '+(r.j.error||'unknown')))}async function aud(){if(!sessionStorage.getItem('nsess')){M('Log in first.');return}M('Opening audio\\u2026');try{var r=await fetch('/audio',{headers:{'X-Nishi-Session':sessionStorage.getItem('nsess')}});if(r.ok){var h=await r.text();var x=window.open('','_blank');x.document.write(h);x.document.close();M('Audio page opened (protected by your session).')}else{M('Audio denied ('+r.status+') \\u2014 login required.')}}catch(e){M('audio error: '+e)}}function out(){sessionStorage.removeItem('nsess');M('Signed out (token cleared).')}</script></body></html>" as *u8
59
60// THE SHARED ROUTE HANDLER. req[0..rn] = the plaintext HTTP request; writes the full HTTP response to
61// resp_out and returns its length. now = sys_now_realtime_sec(). resp_out must be >= ~256 KiB (audio page).
62// ORIGIN-AWARE ROUTE (2026-07-31, debt 1785445694). Signup must be gated on LAN-AND-INVITED, not on a
63// single global boolean. Callers that know the peer address pass it; olg_route below keeps the old
64// 6-arg contract (rule 19) and passes ip4=0, which this file treats as UNKNOWN ORIGIN = WAN = DENY.
65func olg_route_ip(ctx: *NxAuthContext, req: *u8, rn: i64, now: i64, resp_out: *u8, resp_cap: i64,
66 ip4: *u8, hr_store: *u8, realm: *u8, realm_n: i64, regflag: *u8) -> i64 {
67 let he: i64 = olr_find(req, rn, "\r\n\r\n" as *u8, 4)
68 var body: *u8 = req; var bn: i64 = 0
69 if he >= 0 { body = ((req as i64) + he + 4) as *u8; bn = rn - he - 4 }
70
71 if olr_starts(req, rn, "POST /login" as *u8) == 1 {
72 let hb: *u8 = sys_mmap(128); let hl: *i64 = sys_mmap(16) as *i64
73 let pb: *u8 = sys_mmap(320); let pl: *i64 = sys_mmap(16) as *i64
74 nx_http_form_get_field(body, bn, "handle" as *u8, 6, hb, 127, hl)
75 nx_http_form_get_field(body, bn, "pw" as *u8, 2, pb, 319, pl)
76 let b64: *u8 = sys_mmap(512); let b64n: *i64 = sys_mmap(16) as *i64
77 if olg_login(ctx, hb, hl[0], pb, pl[0], b64, 512, b64n) == NX_MAUTH_OK {
78 let jb: *u8 = sys_mmap(640); var o: i64 = olr_cat(jb, 0, "{\"token\":\"" as *u8); o = olr_catb(jb, o, b64, b64n[0]); o = olr_cat(jb, o, "\"}" as *u8)
79 return olr_resp(resp_out, "200 OK" as *u8, "application/json" as *u8, jb, o)
80 }
81 return olr_resp(resp_out, "401 Unauthorized" as *u8, "application/json" as *u8, "{\"error\":\"invalid credentials\"}" as *u8, 31)
82 }
83 if olr_starts(req, rn, "GET /whoami" as *u8) == 1 {
84 let tb: *u8 = sys_mmap(512); let tl: i64 = olr_hdr_val(req, he, "X-Nishi-Session:" as *u8, 16, tb, 512)
85 let uh: *u8 = sys_mmap(64); let uhn: *i64 = sys_mmap(16) as *i64
86 if olg_whoami(ctx, tb, tl, now, uh, 64, uhn) == NX_MAUTH_OK {
87 let jb: *u8 = sys_mmap(128); var o: i64 = olr_cat(jb, 0, "{\"uid\":\"" as *u8); o = olr_hex(jb, o, uh, uhn[0]); o = olr_cat(jb, o, "\"}" as *u8)
88 return olr_resp(resp_out, "200 OK" as *u8, "application/json" as *u8, jb, o)
89 }
90 return olr_resp(resp_out, "401 Unauthorized" as *u8, "application/json" as *u8, "{\"error\":\"session rejected\"}" as *u8, 28)
91 }
92 if olr_starts(req, rn, "GET /audio" as *u8) == 1 {
93 let tb: *u8 = sys_mmap(512); let tl: i64 = olr_hdr_val(req, he, "X-Nishi-Session:" as *u8, 16, tb, 512)
94 let uh: *u8 = sys_mmap(64); let uhn: *i64 = sys_mmap(16) as *i64
95 if olg_whoami(ctx, tb, tl, now, uh, 64, uhn) == NX_MAUTH_OK {
96 let page: *u8 = sys_mmap(OLR_MAGIC_131072); let plen: i64 = nx_audio_web_page(page, OLR_MAGIC_131072)
97 return olr_resp(resp_out, "200 OK" as *u8, "text/html; charset=utf-8" as *u8, page, plen)
98 }
99 return olr_resp(resp_out, "401 Unauthorized" as *u8, "application/json" as *u8, "{\"error\":\"login required\"}" as *u8, 26)
100 }
101 if olr_starts(req, rn, "GET /finance/intake" as *u8) == 1 {
102 let tb: *u8 = sys_mmap(512); let tl: i64 = olr_hdr_val(req, he, "X-Nishi-Session:" as *u8, 16, tb, 512)
103 let uh: *u8 = sys_mmap(64); let uhn: *i64 = sys_mmap(16) as *i64
104 if olg_whoami(ctx, tb, tl, now, uh, 64, uhn) == NX_MAUTH_OK {
105 let page: *u8 = sys_mmap(OLR_MAGIC_131072); let plen: i64 = if_render(page)
106 return olr_resp(resp_out, "200 OK" as *u8, "text/html; charset=utf-8" as *u8, page, plen)
107 }
108 return olr_resp(resp_out, "401 Unauthorized" as *u8, "application/json" as *u8, "{\"error\":\"login required\"}" as *u8, 26)
109 }
110 if olr_starts(req, rn, "POST /finance/intake" as *u8) == 1 {
111 let tb: *u8 = sys_mmap(512); let tl: i64 = olr_hdr_val(req, he, "X-Nishi-Session:" as *u8, 16, tb, 512)
112 let uh: *u8 = sys_mmap(64); let uhn: *i64 = sys_mmap(16) as *i64
113 if olg_whoami(ctx, tb, tl, now, uh, 64, uhn) == NX_MAUTH_OK {
114 let rid: *u8 = sys_mmap(160); let rlen: i64 = olr_hex(rid, 0, uh, uhn[0]); rid[rlen] = 0 as u8
115 let icid: *u8 = sys_mmap(96)
116 if ih_record("knowledge/store/intake-" as *u8, rid, body, bn, icid) == 0 {
117 let svb: *u8 = "<!doctype html><meta charset=utf-8><title>Saved</title><p>Saved. <a href=\"/finance\">See your plan</a></p>" as *u8
118 return olr_resp(resp_out, "200 OK" as *u8, "text/html; charset=utf-8" as *u8, svb, olr_slen(svb))
119 }
120 return olr_resp(resp_out, "500 Internal Server Error" as *u8, "application/json" as *u8, "{\"error\":\"save failed\"}" as *u8, 23)
121 }
122 return olr_resp(resp_out, "401 Unauthorized" as *u8, "application/json" as *u8, "{\"error\":\"login required\"}" as *u8, 26)
123 }
124 if olr_starts(req, rn, "POST /finance/bills" as *u8) == 1 {
125 let tb: *u8 = sys_mmap(512); let tl: i64 = olr_hdr_val(req, he, "X-Nishi-Session:" as *u8, 16, tb, 512)
126 let uh: *u8 = sys_mmap(64); let uhn: *i64 = sys_mmap(16) as *i64
127 if olg_whoami(ctx, tb, tl, now, uh, 64, uhn) == NX_MAUTH_OK {
128 let rid: *u8 = sys_mmap(160); let rlen: i64 = olr_hex(rid, 0, uh, uhn[0]); rid[rlen] = 0 as u8
129 be_record("knowledge/store/bills-" as *u8, rid, body, bn)
130 return olr_resp303(resp_out, "/finance" as *u8)
131 }
132 return olr_resp(resp_out, "401 Unauthorized" as *u8, "application/json" as *u8, "{\"error\":\"login required\"}" as *u8, 26)
133 }
134 if olr_starts(req, rn, "POST /finance/progress" as *u8) == 1 {
135 let tb: *u8 = sys_mmap(512); let tl: i64 = olr_hdr_val(req, he, "X-Nishi-Session:" as *u8, 16, tb, 512)
136 let uh: *u8 = sys_mmap(64); let uhn: *i64 = sys_mmap(16) as *i64
137 if olg_whoami(ctx, tb, tl, now, uh, 64, uhn) == NX_MAUTH_OK {
138 let rid: *u8 = sys_mmap(160); let rlen: i64 = olr_hex(rid, 0, uh, uhn[0]); rid[rlen] = 0 as u8
139 fw_progress_advance(rid)
140 return olr_resp303(resp_out, "/finance" as *u8)
141 }
142 return olr_resp(resp_out, "401 Unauthorized" as *u8, "application/json" as *u8, "{\"error\":\"login required\"}" as *u8, 26)
143 }
144 if olr_starts(req, rn, "GET /finance" as *u8) == 1 {
145 let tb: *u8 = sys_mmap(512); let tl: i64 = olr_hdr_val(req, he, "X-Nishi-Session:" as *u8, 16, tb, 512)
146 let uh: *u8 = sys_mmap(64); let uhn: *i64 = sys_mmap(16) as *i64
147 if olg_whoami(ctx, tb, tl, now, uh, 64, uhn) == NX_MAUTH_OK {
148 let rid: *u8 = sys_mmap(160); let rlen: i64 = olr_hex(rid, 0, uh, uhn[0]); rid[rlen] = 0 as u8
149 let page: *u8 = sys_mmap(OLR_MAGIC_131072); let plen: i64 = fw_render(page, rid)
150 return olr_resp(resp_out, "200 OK" as *u8, "text/html; charset=utf-8" as *u8, page, plen)
151 }
152 return olr_resp(resp_out, "401 Unauthorized" as *u8, "application/json" as *u8, "{\"error\":\"login required\"}" as *u8, 26)
153 }
154 if olr_starts(req, rn, "POST /register" as *u8) == 1 {
155 if olg_registration_open_at(regflag) == 0 {
156 return olr_resp(resp_out, "403 Forbidden" as *u8, "application/json" as *u8, "{\"error\":\"registration closed\"}" as *u8, 31)
157 }
158 let hb: *u8 = sys_mmap(128); let hl: *i64 = sys_mmap(16) as *i64
159 let pb: *u8 = sys_mmap(320); let pl: *i64 = sys_mmap(16) as *i64
160 nx_http_form_get_field(body, bn, "handle" as *u8, 6, hb, 127, hl)
161 nx_http_form_get_field(body, bn, "pw" as *u8, 2, pb, 319, pl)
162 // LAN + INVITE GATE. ip4==0 = caller supplied no peer address = UNKNOWN ORIGIN, treated as WAN
163 // and DENIED. This check MUST precede ls_signup_allowed, which dereferences ip4 immediately via
164 // ls_ip_is_lan -- passing a null through would segfault the login daemon on every request.
165 if (ip4 as i64) == 0 {
166 return olr_resp(resp_out, "403 Forbidden" as *u8, "application/json" as *u8, "{\"error\":\"signup requires LAN origin\"}" as *u8, 38)
167 }
168 if ls_signup_allowed(ip4, hr_store, realm, realm_n, hb, hl[0]) == 0 {
169 return olr_resp(resp_out, "403 Forbidden" as *u8, "application/json" as *u8, "{\"error\":\"signup requires LAN origin and an invite\"}" as *u8, 52)
170 }
171 let mn: *u8 = sys_mmap(512); let mnn: *i64 = sys_mmap(16) as *i64
172 if olg_register(ctx, hb, hl[0], pb, pl[0], mn, 512, mnn) == NX_MAUTH_OK {
173 let jb: *u8 = sys_mmap(640); var o: i64 = olr_cat(jb, 0, "{\"mnemonic\":\"" as *u8); o = olr_catb(jb, o, mn, mnn[0]); o = olr_cat(jb, o, "\"}" as *u8)
174 return olr_resp(resp_out, "200 OK" as *u8, "application/json" as *u8, jb, o)
175 }
176 return olr_resp(resp_out, "400 Bad Request" as *u8, "application/json" as *u8, "{\"error\":\"register failed\"}" as *u8, 27)
177 }
178 // default: the login page
179 return olr_resp(resp_out, "200 OK" as *u8, "text/html; charset=utf-8" as *u8, OLR_HTML, olr_slen(OLR_HTML))
180}
181
182// RULE-19 WRAPPER: the original 6-arg contract, unchanged for every existing caller
183// (nx_opaque_login_tls_daemon, nx_fin_route_gate, ...). It passes ip4=0 = UNKNOWN ORIGIN, so signup is
184// DENIED through this door by construction. A caller that can prove a LAN peer calls olg_route_ip.
185func olg_route(ctx: *NxAuthContext, req: *u8, rn: i64, now: i64, resp_out: *u8, resp_cap: i64) -> i64 {
186 return olg_route_ip(ctx, req, rn, now, resp_out, resp_cap, 0 as *u8, "" as *u8, "" as *u8, 0, OLG_REGFLAG_PATH)
187}