From 22b4fcce096023ec706bab5449805e34a2912643 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 26 Jul 2026 07:06:45 -0500 Subject: [PATCH] feat: add demo mode and ALTCHA integration for better security for internet deployments and showcase functionality --- docker-compose.demo.yml | 63 ++++++ docs/en/identity-and-security.md | 10 + docs/en/installation.md | 11 + meshchatx.rsm | Bin 183488 -> 184726 bytes meshchatx/meshchat.py | 68 +++++- meshchatx/src/backend/altcha_auth.py | 95 ++++++++ meshchatx/src/backend/auth_page_hint.py | 12 + meshchatx/src/backend/demo_mode.py | 100 ++++++++ meshchatx/src/backend/http/middleware.py | 7 +- meshchatx/src/backend/http/register.py | 2 + meshchatx/src/backend/http/routes/auth.py | 31 +++ meshchatx/src/backend/http/routes/lxmf.py | 5 + meshchatx/src/backend/http/ws/dispatch.py | 16 ++ meshchatx/src/frontend/components/App.vue | 3 + .../src/frontend/components/auth/AuthPage.vue | 213 +++++++++++------- .../components/layout/AppShellBanners.vue | 16 ++ meshchatx/src/frontend/js/GlobalState.js | 1 + meshchatx/src/frontend/js/demoMode.js | 9 + meshchatx/src/frontend/locales/de.json | 19 ++ meshchatx/src/frontend/locales/en.json | 19 ++ meshchatx/src/frontend/locales/es.json | 19 ++ meshchatx/src/frontend/locales/fi.json | 19 ++ meshchatx/src/frontend/locales/fr.json | 19 ++ meshchatx/src/frontend/locales/it.json | 19 ++ meshchatx/src/frontend/locales/nl.json | 19 ++ meshchatx/src/frontend/locales/ru.json | 19 ++ meshchatx/src/frontend/locales/zh.json | 19 ++ meshchatx/src/frontend/main.js | 7 + package.json | 1 + pnpm-lock.yaml | 17 +- scripts/ci/docker-demo-smoke.sh | 61 +++++ tests/backend/conftest.py | 6 +- tests/backend/demo_http_support.py | 25 ++ .../test_access_attempts_enforcement.py | 2 +- tests/backend/test_altcha_oracle.py | 104 +++++++++ tests/backend/test_csp_logic.py | 10 +- .../backend/test_deferred_network_startup.py | 12 +- tests/backend/test_demo_auth_oracle.py | 28 +++ tests/backend/test_demo_mode_http_oracle.py | 70 ++++++ tests/backend/test_demo_mode_mesh.py | 19 ++ .../test_demo_mode_websocket_oracle.py | 17 ++ tests/backend/test_docker_runtime_smoke.py | 25 ++ .../backend/test_identity_restore_http_api.py | 6 +- .../backend/test_identity_switch_http_api.py | 6 +- tests/backend/test_lxmf_cancel_api.py | 6 +- tests/backend/test_media_http_api.py | 6 +- .../test_notification_user_facing_filter.py | 6 +- tests/backend/test_notifications.py | 6 +- tests/backend/test_rnode_download_firmware.py | 6 +- tests/backend/test_telephone_audio_ws.py | 6 +- tests/frontend/AuthPage.test.js | 26 +++ 51 files changed, 1201 insertions(+), 110 deletions(-) create mode 100644 docker-compose.demo.yml create mode 100644 meshchatx/src/backend/altcha_auth.py create mode 100644 meshchatx/src/backend/auth_page_hint.py create mode 100644 meshchatx/src/backend/demo_mode.py create mode 100644 meshchatx/src/frontend/js/demoMode.js create mode 100755 scripts/ci/docker-demo-smoke.sh create mode 100644 tests/backend/demo_http_support.py create mode 100644 tests/backend/test_altcha_oracle.py create mode 100644 tests/backend/test_demo_auth_oracle.py create mode 100644 tests/backend/test_demo_mode_http_oracle.py create mode 100644 tests/backend/test_demo_mode_mesh.py create mode 100644 tests/backend/test_demo_mode_websocket_oracle.py diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml new file mode 100644 index 00000000..7adc71cd --- /dev/null +++ b/docker-compose.demo.yml @@ -0,0 +1,63 @@ +# Coolify public demo stack (read-only mesh showcase, ALTCHA, session auth). +# https://coolify.io/docs/knowledge-base/docker/compose +# +# Set MESHCHAT_ALTCHA_HMAC_KEY in Coolify before deploy (required below). +# Showcase login password defaults to demo (override MESHCHAT_DEMO_AUTH_PASSWORD). +# +# Assign a domain in Coolify with container port 8000, for example: +# https://meshchatx.example.com:8000 +# The :8000 suffix is the container port for the proxy, not the public URL port. +# +# Coolify terminates TLS at the proxy. Plain HTTP inside the container +# (MESHCHAT_NO_HTTPS=1) avoids a self-signed HTTPS backend. Do not add host +# ports unless you intend to bypass the proxy. + +services: + meshchatx: + image: ${MESHCHAT_IMAGE:-quad4io/meshchatx:latest} + restart: unless-stopped + init: true + user: "1000:1000" + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp:noexec,nosuid,size=256m + - /home/meshchat:nosuid,size=64m + environment: + - SERVICE_URL_MESHCHATX_8000 + - MESHCHAT_DEMO_MODE=${MESHCHAT_DEMO_MODE:-1} + - MESHCHAT_AUTH=${MESHCHAT_AUTH:-1} + - MESHCHAT_AUTH_BYPASS=${MESHCHAT_AUTH_BYPASS:-0} + - MESHCHAT_ALTCHA_ENABLED=${MESHCHAT_ALTCHA_ENABLED:-1} + - MESHCHAT_ALTCHA_HMAC_KEY=${MESHCHAT_ALTCHA_HMAC_KEY:?} + - MESHCHAT_DEMO_AUTH_PASSWORD=${MESHCHAT_DEMO_AUTH_PASSWORD:-demo} + - "MESHCHAT_AUTH_PAGE_HINT=${MESHCHAT_AUTH_PAGE_HINT:-Username: demo, Password: demo}" + - MESHCHAT_DISABLE_PLUGINS=${MESHCHAT_DISABLE_PLUGINS:-1} + - MESHCHAT_HOST=${MESHCHAT_HOST:-0.0.0.0} + - MESHCHAT_PORT=${MESHCHAT_PORT:-8000} + - MESHCHAT_NO_HTTPS=${MESHCHAT_NO_HTTPS:-1} + - MESHCHAT_HEADLESS=${MESHCHAT_HEADLESS:-1} + volumes: + - meshchatx-config:/config + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/status')", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 90s + cpus: 2.0 + mem_limit: 1g + mem_reservation: 256m + pids_limit: 512 + +volumes: + meshchatx-config: diff --git a/docs/en/identity-and-security.md b/docs/en/identity-and-security.md index 30457f75..d96a1c6f 100644 --- a/docs/en/identity-and-security.md +++ b/docs/en/identity-and-security.md @@ -36,6 +36,16 @@ Access attempts are logged. Repeated failures can trigger lockout when auth is e Reset a forgotten password with `--reset-password` or `MESHCHAT_RESET_PASSWORD=true`, then set a new password in the UI. +### Demo mode and ALTCHA + +`MESHCHAT_DEMO_MODE=1` (or `--demo`) enables a public showcase profile: privacy mode on, plugins off, no outbound announces, and a default-deny HTTP mutation policy with mesh send blocked. Status reports `demo_mode: true`. + +When `MESHCHAT_ALTCHA_ENABLED=1`, login and setup require a valid [ALTCHA](https://altcha.org/docs/v2/widget-v3/) proof-of-work payload (widget v3, server challenges use `PBKDF2/SHA-256` by default). Set `MESHCHAT_ALTCHA_HMAC_KEY` to a long random secret on the server. Optional `MESHCHAT_ALTCHA_COST` tunes PoW difficulty. The widget loads from the bundled `altcha` npm package and fetches challenges from `/api/v1/auth/altcha/challenge`. + +`MESHCHAT_AUTH_PAGE_HINT` sets optional plain text on the login page (independent of demo mode). Demo Docker compose defaults to username and password hints for the showcase account. + +`MESHCHAT_AUTH_BYPASS=1` skips session auth for local testing only. Do not use it on internet-facing deployments. + ## Transport security - HTTPS and WSS are on by default. diff --git a/docs/en/installation.md b/docs/en/installation.md index 2cded43d..90115cdf 100644 --- a/docs/en/installation.md +++ b/docs/en/installation.md @@ -61,6 +61,17 @@ Default Compose maps `127.0.0.1:8000` on the host to port `8000` in the containe To bind a host directory instead, mount it at `/config`. The container runs as UID 1000. The host directory must be writable by that user. +### Public demo instance (Coolify) + +For a read-only mesh showcase on [Coolify](https://coolify.io/docs/knowledge-base/docker/compose), deploy [`docker-compose.demo.yml`](../../docker-compose.demo.yml). For a normal (non-demo) Coolify deployment, use [`docker-compose.coolify.yml`](../../docker-compose.coolify.yml). + +- `MESHCHAT_DEMO_MODE=1` blocks outbound mesh actions and almost all API mutations. +- `MESHCHAT_AUTH=1` with default showcase password `demo` (`MESHCHAT_DEMO_AUTH_PASSWORD`). +- Optional `MESHCHAT_AUTH_PAGE_HINT` shows custom text on the login page (for example `Username: demo` and `Password: demo`). Demo compose sets a default hint. +- `MESHCHAT_ALTCHA_ENABLED=1` and a strong `MESHCHAT_ALTCHA_HMAC_KEY` (required in demo compose via `:?`). The UI uses ALTCHA widget v3 with `PBKDF2/SHA-256` challenges from `/api/v1/auth/altcha/challenge`. +- Assign a domain with container port **8000**, for example `https://meshchatx.example.com:8000`. +- Do not set `MESHCHAT_AUTH_BYPASS=1` on a public host. + ## Python wheel 1. Download `reticulum_meshchatx-*-py3-none-any.whl` from [releases](https://github.com/Quad4-Software/MeshChatX/releases). diff --git a/meshchatx.rsm b/meshchatx.rsm index 1197aaa27adcbccfc605b1abbd7d44617e384443..41ea8f62677bc7c7e053719433f96de71e40ce62 100644 GIT binary patch delta 4081 zcmYM1S%_X|8OJ?SDwDOXwy{kcopiZSY4YximV94vi5JEV6|JsAdz{j9VV>V+tRl@ULvURW5bFY>AY#>C2r6#BGgB<&Vls2ibDrmU|J(cB z2X=k#OS`7;yLL~x|4$G8yghQocmH$Y)9&|YCx7_N^7>1jy#Ja%e&ON!cD*nAyKnvL zI~HF4=Q(TcQ)aFC(B?@IlHY%6qmX_*DxudP#SXNG+URi60YCE~I`IDQTx@4-O zXx&<&RE@%!psk3$CM%mX+Ugj*w^jP4q8GxdUR@V9sF16ZR)n_k^y{ym#9C~1!g%Ye zRZMXnwy1he(t2LaLfh7| zG%;q?<@nKLTj}1kqO3~BMVnJL*;ViR=HG7Hy)X?T`RY=L+4P!=_BrwC6m4n3xyUPY zV5DJUsk)8Us7~J|vj`uZ-ncnkwe!8}eZE}90DT5GZD9;CBE)~cJf zP8#8rOxf}GBvcRDNKvDSwr8)IGaDaUjA1Oghic2G)4SX9iQ7-DZ~nA?_3~+D5?8b^ zG*xSGDM!eCmwjcaCdJMJF7>1ofXRw-zE)w(#)Z}W8^`Z?{^k$vde<;bzBjE} z?aFn~WVMjF8ZctJZy^bnl}Kz$IYBEi7n`dpu7=dANV04k{K)m2pZxIGFP9sgheC7qvM#_tC?XWV- zlS62-v8y1#W7MGDrxtrmrAgiRS?P_-p15K2rH`kb6VusPY}mi?==oine|~au$s`yXn$N)%n^I~FAgj_cwBEDS zIzU8IglRM_CQ53MaBTax8`ASswL-<0iN^S*8m?_Q?^z)L`g~{qh3;JwC3i2w~-SCtY;A{)X~tAw{j)AsmWL=vr6PD?~d&YpSwcBRFmt7o<+ zg)mBqg2`B{GIn;$3nk}JCI3&DV$@MttwN;##6C0DTuMpaS@(a`9sR(G?Sj2q(Gw3q z1uP)QrW$7!avBw^Aqy%g4g8`E|{0qv@TfWBJ==TFulFHHl=AL)%5tI4X^=Cbx` z6iVB1V&`zyQF>WJ_XYnRQEiiI@IhujKY7pa$ig(&COYS@jj2Te*$_-HO@)H7^w6;( zC4#^jU>#(RjTt3Wz_o-cY5vB0hX?lDR){AY2QFEW4h9%s$hZLYOSc6STX0!B0v%0N zj2vo`y2ALPTwPjPE33DkUSB(0Za@5Nk{K1?#>oT{&j0$wRHsJ~HM)vQ89cDr1#>ySKB>%4#a_ZS#*mF??{}gm@=V zCX1l;fKTSwQ$$+57%g05vEF5oI4jocWFl#VPFy2+Bx}O#&DSpMo8R*5;hh&vFm|5s zv@bYUCe5(|{?2rSE~SYhU63pV)T|x%RrohJ@1xk5G<#;x!oJz!Z-;9prU{KD)Bw2= zB+eJTjAO_m@CsCkOcp({t_gu47c4IUBV;R&(zDgnTegm4%yrA#10Onlc4wf1vDi>R zlu-LD2n@awFN*>!joF<+9`Ja+rmE-f6+u@>pOdrU@577Uxm9-zVcTc@T=A4p9jshb zPMUm)OH~mFBk-=th*+SeCgPXqIJ+inC9efpnxKunjAwMko1ZIxvaOvMn`Vi*V~IFM zD4J)nAo2-OhQ(mY4xAcjJJA7|A z(g8m{oKlHK6hTym0fYq5mO|lX05^zXwRR`GH`q0noo$0PaAo${3&V@%{&&OC9Y+X{ zTumZAOd@qj#oD4KSB*(y%)Ibc$?C>KzSJAV^bqiTH!rBU34baTT~FVF-5~g+%c+99)Kw6na#cJe$M7 z08?o+h*61Hv*A8~+5wu-NMqROXu`^EtJxJZ@rs3=^B={9hxSiNecW7}RyZ&S)dfkx mlr`d>T+N>P+-^fO$rC@h_;R-%ld?{dacZA`?X3%!-}ygzjK+5W delta 3059 zcmYk8U96N>6^8Q-Vxj!CrXCQg9?}wRVqpKQ{TEYa+SuStG3|v~Fg9!Ny|zjlYNc4B zu|Hxk)@Xof`q}7BHBnn)3{K*3p`@o=G!#+MNNi#oNrR1zHB#@q!S@4VJQtHW=ghZe z?X}+bdG|9vTJ_?yt0o)nIehHM($@WN?R@dT?u|De`uN%B_ip&e*5gmt2S5GA8(;X% zKVN?4^zCn+zU|(_-*L+hy?*S(n|Jv)K6!cb*EVhVvU>f(sw0yPi|fAe>Yfu%?LK+x zFZ3h!Hwrl;li^o>+o*&%3`+b+ceTt79*f%=!$K!9m_0gHW{rtK~lVWSszC<=U zBu-*g(Ly#0Q_U*Xnw;ZUZ3sll*veeJP-QR8+A`g7_pNtm_C%tiW+&oZ^PU>zW>`m` zy(^YeW~D8A<0a)<$yDEbqLOl9wYb=?d9>R#d-u+jH%?TnIT_$&Q&tkasVGU@lHpv8mZV#ped7MrquEP*e%Wl)etgqx%~uaCpI94Q37(SGWTG(< zE$J%BQ{-q>vo3HeK14!KRhe2%5S&V!n397`mmYin^j}Y`-L8qNX@Q+>-swQPT9>Mz zC!g1HoT4Mo4teM{P-7T|C2Y@H5JXTBORl<2AAWlC><>o|Zks42+GA6Ar6ws^Dk_E|IXg)Odu%OP?@ZB7 zl&XzmX{yGgGYML?)9%!VX1{vrv)7Nck5;gCPIZu*uC6Jae0Htb4BcAeYOoS>v^vBT zO7Y1mYg%qav!3$#EwjhYj@OMju*uOEd|Z(+AwsEHvG_s_22d1q%GxNaVTFoSBytVH z$3C>g*mv*hw#+uawshCTBtaFITr^ZsF$RaB``F+RY~Q6g96P&M;i44aC=_JQCMAIg ze9s^5Zk}(yrCVA(|D5XXHDiOdvp{z^i1FDPQBK#&IVRMv2BlD*a!IM-Ln^aQ41;;= zjnid%{N;`P2c=uFqsrDj`ySeReU|Kf8U`$(`Issx=Td`Wq0$T&Bu3L1mBvS^Q?-<2 zgwcFs2zC10(zSxJ_i90%O~X``NYoM5b2;c271{DSuG>p4K$%8atkKsQB8YU{1GN__!3pHk2VixZK5gsc7CFc$F5!U5zelcI=E zrV_LiaM$Df*+-|63%WHJKEauQrpsa)W8gBq^vbol9maYmI%Bx#y{HCv$C5p`8YAP-T@9~FkF8}>o)KB(*A2A0iQsz4C|G_2heURa8zJP@JeHX zo@0buKu-mi*W7}Rz#BLv>HoIymCs+TSQRNG83`a!wit4<27G#ns9Kq_L7 zRvS;SkP1{2kWSNcuWp>5YF%EOKo+Heiorl&2pq5y`d3H=G8T-EFeNOZat7F4D!>*| zOe!N;O44O6-|T)kn$%)Q8!XKr-HZfb15&}M-lBFw#X2|=D#fB<^MOH`UBw`kHi_mt zp6Z@mGr#kD-IiNF1?*Me37J@iq{C)Qj1~CS@MWE|OP5LzpSZLXbQKNJl)u z>jMsiy~j$RUyrlUpm(?(J$UXfobBE_fBZss-?DLZRdflkQGoQt;C9e(4bT8k4bGMn zka}d91KLiCGEVJbvPeZDN&oO0XmIPrZqxiff9pCfPx$2r)XPE z%CH7lQH+C|7+}^pTj01+?VtQJIzzb!*N$6**RmEx5Gex1W48&p41^-$c}NIPxaAZi zm#7e11AK<`#?`d>++TVPr4DZy{c^)NK<>#8(TOwylu1WU$i!H{@5UDQEQJR_YFsxX z7mUzpNZ#N!qH?&Rk@@$xkAAXoLNE+2F}~pv1BqiOT7+#h4LgQ#3l8c_Ksah-5x_~v T5yqtpK>$db`Pc6o-E`o;Q!tg? diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index e2f7cbb3..82de10f9 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -81,6 +81,12 @@ from meshchatx.src.backend.csrf import ( rotate_session_csrf_token, validate_csrf_header, ) +from meshchatx.src.backend.altcha_auth import altcha_enabled_from_env +from meshchatx.src.backend.auth_page_hint import auth_page_hint_from_env +from meshchatx.src.backend.demo_mode import ( + auth_bypass_from_env, + demo_auth_password_from_env, +) from meshchatx.src.backend.database.access_attempts import ( LOGIN_PATH, MAX_FAILED_BEFORE_LOCKOUT, @@ -560,9 +566,14 @@ class ReticulumMeshChat: plugins_enabled: bool = True, defer_network_setup: bool = False, headless: bool = False, + demo_mode: bool = False, + altcha_enabled: bool = False, ): self.running = True self.plugins_enabled = plugins_enabled + self.demo_mode = bool(demo_mode) + self.altcha_enabled = bool(altcha_enabled) + self.auth_page_hint = auth_page_hint_from_env() self._memory_diag_enabled = memory_diag_enabled self._mem_diag = None self._headless = bool(headless) @@ -1034,6 +1045,8 @@ class ReticulumMeshChat: @property def auth_enabled(self): + if auth_bypass_from_env(): + return False if self.config: return self.config.auth_enabled.get() return self.auth_enabled_initial @@ -1682,6 +1695,11 @@ class ReticulumMeshChat: self._sideband_telemetry_running = False def _startup_status_payload(self) -> dict: + demo_fields = { + "demo_mode": self.demo_mode, + "altcha_enabled": self.altcha_enabled, + "auth_page_hint": self.auth_page_hint, + } if self._startup_stage == "failed" or self._startup_error: payload = { "status": "failed", @@ -1694,6 +1712,7 @@ class ReticulumMeshChat: "https_enabled": self.use_https, "is_loopback_bind": _is_loopback_bind_host(self.listen_host), "plugins_enabled": self.plugins_enabled, + **demo_fields, **self._landlock_status_dict(), } if self._startup_error: @@ -1716,6 +1735,7 @@ class ReticulumMeshChat: "https_enabled": self.use_https, "is_loopback_bind": _is_loopback_bind_host(self.listen_host), "plugins_enabled": self.plugins_enabled, + **demo_fields, **self._landlock_status_dict(), } @@ -1831,6 +1851,7 @@ class ReticulumMeshChat: ) if self._network_ready: self._finish_deferred_startup_services() + self._apply_demo_mode_runtime() return # Initialize Reticulum if not already done @@ -1881,6 +1902,27 @@ class ReticulumMeshChat: if self._network_ready: self._finish_deferred_startup_services() + self._apply_demo_mode_runtime() + + def _apply_demo_mode_runtime(self) -> None: + if not self.demo_mode: + return + ctx = self.current_context + if not ctx or not ctx.config: + return + self.plugins_enabled = False + ctx.config.privacy_mode_enabled.set(True) + ctx.config.auto_announce_enabled.set(False) + if self.auth_enabled_initial: + ctx.config.auth_enabled.set(True) + if self.auth_enabled and ctx.config.auth_password_hash.get() is None: + password = demo_auth_password_from_env() + password_hash = bcrypt.hashpw( + password.encode("utf-8"), + bcrypt.gensalt(), + ).decode("utf-8") + ctx.config.auth_password_hash.set(password_hash) + def _finish_deferred_startup_services(self) -> None: """Start non-critical services after network_ready is published.""" context = self.current_context @@ -5106,6 +5148,7 @@ class ReticulumMeshChat: security_middleware, csrf_middleware, ip_allowlist_middleware, + demo_mode_middleware, ) = register_all_routes(routes, self) return ( @@ -5114,6 +5157,7 @@ class ReticulumMeshChat: security_middleware, csrf_middleware, ip_allowlist_middleware, + demo_mode_middleware, ) def _encrypted_cookie_storage(self, use_https: bool) -> EncryptedCookieStorage: @@ -5215,6 +5259,7 @@ class ReticulumMeshChat: security_middleware, csrf_middleware, ip_allowlist_middleware, + demo_mode_middleware, ) = self._define_routes(routes) ssl_context = None @@ -5324,6 +5369,7 @@ class ReticulumMeshChat: security_middleware, csrf_middleware, ip_allowlist_middleware, + demo_mode_middleware, ], ) @@ -5521,6 +5567,8 @@ class ReticulumMeshChat: # handle announcing async def announce(self, context=None): + if self.demo_mode: + return ctx = context or self.current_context if not ctx: return @@ -10386,6 +10434,12 @@ def main(): default=env_bool("MESHCHAT_AUTH", False), help="Enable basic authentication for the web interface. Can also be set via MESHCHAT_AUTH environment variable.", ) + parser.add_argument( + "--demo", + action="store_true", + default=env_bool("MESHCHAT_DEMO_MODE", False), + help="Public demo mode: read-only mesh and blocked API mutations. Can also be set via MESHCHAT_DEMO_MODE environment variable.", + ) parser.add_argument( "--no-https", action="store_true", @@ -10648,6 +10702,16 @@ def main(): or args.restore_db or args.restore_from_snapshot, ) + if auth_bypass_from_env(): + print( + "WARNING: MESHCHAT_AUTH_BYPASS=1 disables web UI authentication", + file=sys.stderr, + flush=True, + ) + + demo_mode = bool(args.demo) + altcha_on = altcha_enabled_from_env() + reticulum_meshchat = ReticulumMeshChat( identity, args.storage_dir, @@ -10663,9 +10727,11 @@ def main(): rns_loglevel=rns_log_cli, migration_context=migration_context, memory_diag_enabled=args.memory_diag, - plugins_enabled=not args.disable_plugins, + plugins_enabled=(not args.disable_plugins) and not demo_mode, defer_network_setup=not needs_immediate_network, headless=bool(args.headless), + demo_mode=demo_mode, + altcha_enabled=altcha_on, ) # store recovery on app for wiring with identity context diff --git a/meshchatx/src/backend/altcha_auth.py b/meshchatx/src/backend/altcha_auth.py new file mode 100644 index 00000000..7fd39ab6 --- /dev/null +++ b/meshchatx/src/backend/altcha_auth.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: 0BSD + +"""ALTCHA proof-of-work verification for login and setup.""" + +from __future__ import annotations + +import os +import time +from typing import Any + +import altcha +from aiohttp import web + +from meshchatx.src.env_utils import env_bool + +ALTCHA_INVALID_CODE = "altcha_invalid" +ALTCHA_ALGORITHM = "PBKDF2/SHA-256" +ALTCHA_DEFAULT_COST = 2000 + + +def altcha_enabled_from_env() -> bool: + return env_bool("MESHCHAT_ALTCHA_ENABLED", False) + + +def altcha_hmac_secret() -> str | None: + raw = os.environ.get("MESHCHAT_ALTCHA_HMAC_KEY", "").strip() + return raw or None + + +def altcha_configured() -> bool: + return altcha_enabled_from_env() and bool(altcha_hmac_secret()) + + +def altcha_pow_cost() -> int: + raw = os.environ.get("MESHCHAT_ALTCHA_COST", "").strip() + if not raw: + return ALTCHA_DEFAULT_COST + try: + return max(100, int(raw)) + except ValueError: + return ALTCHA_DEFAULT_COST + + +def create_altcha_challenge_dict() -> dict[str, Any]: + secret = altcha_hmac_secret() + if not secret: + msg = "MESHCHAT_ALTCHA_HMAC_KEY is required when ALTCHA is enabled" + raise RuntimeError(msg) + expires_at = int(time.time()) + 300 + challenge = altcha.create_challenge( + ALTCHA_ALGORITHM, + altcha_pow_cost(), + hmac_secret=secret, + expires_at=expires_at, + ) + return challenge.to_dict() + + +def verify_altcha_submission(payload: Any) -> tuple[bool, str | None]: + secret = altcha_hmac_secret() + if not secret: + return False, "altcha_not_configured" + if payload is None: + return False, ALTCHA_INVALID_CODE + if isinstance(payload, dict): + import json + + payload = json.dumps(payload) + if not isinstance(payload, str) or not payload.strip(): + return False, ALTCHA_INVALID_CODE + try: + result = altcha.verify_solution(payload.strip(), secret) + except Exception: + return False, ALTCHA_INVALID_CODE + if not result.verified: + err = result.error or ALTCHA_INVALID_CODE + return False, err + return True, None + + +def altcha_error_response(code: str) -> web.Response: + return web.json_response( + {"error": "ALTCHA verification failed", "code": code}, + status=400, + ) + + +async def require_altcha_payload(request, data: dict) -> web.Response | None: + if not altcha_enabled_from_env(): + return None + payload = data.get("altcha") + ok, code = verify_altcha_submission(payload) + if not ok: + return altcha_error_response(code or ALTCHA_INVALID_CODE) + return None diff --git a/meshchatx/src/backend/auth_page_hint.py b/meshchatx/src/backend/auth_page_hint.py new file mode 100644 index 00000000..3bb63a12 --- /dev/null +++ b/meshchatx/src/backend/auth_page_hint.py @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: 0BSD + +"""Optional login page hint text from environment.""" + +from __future__ import annotations + +import os + + +def auth_page_hint_from_env() -> str | None: + raw = os.environ.get("MESHCHAT_AUTH_PAGE_HINT", "").strip() + return raw or None diff --git a/meshchatx/src/backend/demo_mode.py b/meshchatx/src/backend/demo_mode.py new file mode 100644 index 00000000..9605a6d1 --- /dev/null +++ b/meshchatx/src/backend/demo_mode.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: 0BSD + +"""Public demo mode: read-only mesh and default-deny HTTP mutations.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from aiohttp import web + +from meshchatx.src.env_utils import env_bool + +if TYPE_CHECKING: + from meshchatx.meshchat import ReticulumMeshChat + +DEMO_READONLY_CODE = "demo_readonly" + +DEMO_HTTP_MUTATION_ALLOWLIST: frozenset[str] = frozenset( + { + "/api/v1/auth/login", + "/api/v1/auth/logout", + "/api/v1/auth/setup", + "/api/v1/app/tutorial/seen", + "/api/v1/app/changelog/seen", + }, +) + + +def demo_mode_from_env() -> bool: + return env_bool("MESHCHAT_DEMO_MODE", False) + + +def auth_bypass_from_env() -> bool: + return env_bool("MESHCHAT_AUTH_BYPASS", False) + + +def demo_auth_password_from_env() -> str: + return os.environ.get("MESHCHAT_DEMO_AUTH_PASSWORD", "demo") + + +def normalize_api_path(path: str) -> str: + if not path.startswith("/api/"): + return path + parts = [p for p in path.split("/") if p] + return "/" + "/".join(parts) + + +def demo_mode_active(app: ReticulumMeshChat) -> bool: + return bool(getattr(app, "demo_mode", False)) + + +def demo_mode_blocks_ws_type(app: ReticulumMeshChat, msg_type: str) -> bool: + if not demo_mode_active(app): + return False + from meshchatx.src.backend.websocket_config_guard import WEBSOCKET_MUTATOR_TYPES + + return msg_type in WEBSOCKET_MUTATOR_TYPES + + +def demo_mode_block_response(app: ReticulumMeshChat) -> web.Response | None: + if not demo_mode_active(app): + return None + return web.json_response( + { + "error": "Demo mode is read-only", + "code": DEMO_READONLY_CODE, + }, + status=403, + ) + + +def demo_http_mutation_allowed(method: str, path: str) -> bool: + if method.upper() in ("GET", "HEAD", "OPTIONS"): + return True + if method.upper() not in ("POST", "PUT", "PATCH", "DELETE"): + return True + normalized = normalize_api_path(path) + if not normalized.startswith("/api/v1/"): + return True + return normalized in DEMO_HTTP_MUTATION_ALLOWLIST + + +def create_demo_mode_middleware(app: ReticulumMeshChat): + @web.middleware + async def demo_mode_middleware(request, handler): + if demo_mode_active(app) and not demo_http_mutation_allowed( + request.method, + request.path, + ): + return web.json_response( + { + "error": "Demo mode is read-only", + "code": DEMO_READONLY_CODE, + }, + status=403, + ) + return await handler(request) + + return demo_mode_middleware diff --git a/meshchatx/src/backend/http/middleware.py b/meshchatx/src/backend/http/middleware.py index ec2c75e7..2d49bffc 100644 --- a/meshchatx/src/backend/http/middleware.py +++ b/meshchatx/src/backend/http/middleware.py @@ -94,7 +94,11 @@ def create_auth_middleware(app): # Allow CSRF bootstrap and auth status while the network stack starts so the # Vue shell can load and show an in-app waiting state. - if path in ("/api/v1/auth/csrf", "/api/v1/auth/status"): + if path in ( + "/api/v1/auth/csrf", + "/api/v1/auth/status", + "/api/v1/auth/altcha/challenge", + ): return await handler(request) # Serve the web UI shell and static files while an identity context is still @@ -149,6 +153,7 @@ def create_auth_middleware(app): "/api/v1/auth/login", "/api/v1/auth/status", "/api/v1/auth/logout", + "/api/v1/auth/altcha/challenge", "/manifest.json", "/service-worker.js", ] diff --git a/meshchatx/src/backend/http/register.py b/meshchatx/src/backend/http/register.py index 03ce8ff1..28370739 100644 --- a/meshchatx/src/backend/http/register.py +++ b/meshchatx/src/backend/http/register.py @@ -4,6 +4,7 @@ from __future__ import annotations +from meshchatx.src.backend.demo_mode import create_demo_mode_middleware from meshchatx.src.backend.http.middleware import ( create_auth_middleware, create_csrf_middleware, @@ -28,4 +29,5 @@ def register_all_routes(routes, app): create_security_middleware(app), create_csrf_middleware(app), create_ip_allowlist_middleware(app), + create_demo_mode_middleware(app), ) diff --git a/meshchatx/src/backend/http/routes/auth.py b/meshchatx/src/backend/http/routes/auth.py index e784a271..c29458cb 100644 --- a/meshchatx/src/backend/http/routes/auth.py +++ b/meshchatx/src/backend/http/routes/auth.py @@ -134,6 +134,18 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401 def register_auth_routes(routes, app): + @routes.get("/api/v1/auth/altcha/challenge") + async def auth_altcha_challenge(request): + if not app.altcha_enabled: + return web.json_response({"error": "ALTCHA is not enabled"}, status=404) + try: + from meshchatx.src.backend.altcha_auth import create_altcha_challenge_dict + + challenge = create_altcha_challenge_dict() + except RuntimeError as exc: + return web.json_response({"error": str(exc)}, status=503) + return web.json_response(challenge) + @routes.get("/api/v1/server/security") async def server_security_get(request): settings = load_app_security_settings(app.storage_dir) @@ -208,6 +220,9 @@ def register_auth_routes(routes, app): "network_ready": False, "status": "starting", "stage": app._startup_stage, + "demo_mode": app.demo_mode, + "altcha_enabled": app.altcha_enabled, + "auth_page_hint": app.auth_page_hint, }, ) try: @@ -226,6 +241,9 @@ def register_auth_routes(routes, app): "password_set": app.config.auth_password_hash.get() is not None, "authenticated": actually_authenticated, "network_ready": True, + "demo_mode": app.demo_mode, + "altcha_enabled": app.altcha_enabled, + "auth_page_hint": app.auth_page_hint, }, ) except Exception as e: @@ -242,6 +260,9 @@ def register_auth_routes(routes, app): "network_ready": bool( app.current_context and app.current_context.running, ), + "demo_mode": app.demo_mode, + "altcha_enabled": app.altcha_enabled, + "auth_page_hint": app.auth_page_hint, "error": str(e), }, ) @@ -298,6 +319,11 @@ def register_auth_routes(routes, app): {"error": "Invalid request body"}, status=400, ) + from meshchatx.src.backend.altcha_auth import require_altcha_payload + + altcha_blocked = await require_altcha_payload(request, data) + if altcha_blocked is not None: + return altcha_blocked password = data.get("password") if not password or len(password) < 8: @@ -380,6 +406,11 @@ def register_auth_routes(routes, app): {"error": "Invalid request body"}, status=400, ) + from meshchatx.src.backend.altcha_auth import require_altcha_payload + + altcha_blocked = await require_altcha_payload(request, data) + if altcha_blocked is not None: + return altcha_blocked password = data.get("password") password_hash = app.config.auth_password_hash.get() diff --git a/meshchatx/src/backend/http/routes/lxmf.py b/meshchatx/src/backend/http/routes/lxmf.py index 8996bff7..caa83c41 100644 --- a/meshchatx/src/backend/http/routes/lxmf.py +++ b/meshchatx/src/backend/http/routes/lxmf.py @@ -532,6 +532,11 @@ def register_lxmf_routes(routes, app): # send lxmf message @routes.post("/api/v1/lxmf-messages/send") async def lxmf_messages_send(request): + from meshchatx.src.backend.demo_mode import demo_mode_block_response + + blocked = demo_mode_block_response(app) + if blocked is not None: + return blocked # get request body as json data = await request.json() diff --git a/meshchatx/src/backend/http/ws/dispatch.py b/meshchatx/src/backend/http/ws/dispatch.py index ec8c85f4..f7e48d67 100644 --- a/meshchatx/src/backend/http/ws/dispatch.py +++ b/meshchatx/src/backend/http/ws/dispatch.py @@ -4,6 +4,7 @@ from __future__ import annotations from meshchatx.src.backend.http.live_names import inject_meshchat_names +from meshchatx.src.backend.demo_mode import demo_mode_blocks_ws_type from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401 AsyncUtils, json, @@ -54,6 +55,21 @@ async def dispatch_websocket_data(app, client, data): if not _type: return + if demo_mode_blocks_ws_type(app, _type): + logger.warning("Rejected WebSocket mutator in demo mode: %s", _type) + AsyncUtils.run_async( + client.send_str( + json.dumps( + { + "type": "error", + "message": "Demo mode is read-only", + "code": "demo_readonly", + }, + ), + ), + ) + return + if websocket_type_requires_auth(_type): if not await app._websocket_session_authorized(client): logger.warning("Rejected unauthorized WebSocket mutator: %s", _type) diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 41ddfed4..fce443a3 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -9,6 +9,8 @@