# Security Jun OS is a fan project you run on your own machine. This file says what it assumes about that machine, what it fetches and from where, what it stores, and how to report something you find. ## Threat model **What the design assumes** * One person, one trusted machine. The first account on a fresh database can be created without the registration key, but only while `BIND_ADDR` is loopback. Later accounts, and the first one on an install that is already listening off loopback, require `OMEGA_REGISTRATION_KEY` unless you deliberately leave it empty. * The person installing it is the machine's administrator, and is allowed to install Docker, Python and PHP on it. * Whatever a model says is untrusted input. Lore, web search results, song lyrics and chat history all get fed back into the prompt, and none of it is a trusted instruction. **What it defends against** * *Being reachable by accident.* nginx publishes on `127.0.0.1` only, unless you set `BIND_ADDR` (see [Exposing it on purpose](#exposing-it-on-purpose)). On Windows every process binds `127.0.0.1` too. * *A request forged by another page.* The session and data-key cookies are `HttpOnly`, `SameSite=Strict`, and every write checks `Sec-Fetch-Site` / `Origin` and refuses anything that isn't this exact origin. That last check matters more than it looks: to a browser, `localhost` is a single site whatever the port, so any other local app serving you a page counts as same-site and would otherwise have its forged requests carry your cookie. * *Installing something other than what you read.* See [Install-time supply chain](#install-time-supply-chain). * *Brute force and runaway cost.* Per-IP token buckets on the auth and chat endpoints, request size caps, and a body-size limit in PHP. If the state directory those buckets live in is unwritable the request fails with 503 rather than sailing through unlimited. * *A model server that stalls or never stops.* One chat turn gets a wall-clock ceiling (`OMEGA_TURN_TIMEOUT_S`, 900 s), an idle limit (`OMEGA_STREAM_IDLE_S`, 300 s), and byte caps on the stream (1 MiB per frame, 4 MiB of content, 64 KiB of error body); a thinking turn is capped at 16k generated tokens. A logged-in user cannot hold a PHP worker or spend OpenRouter credit without bound. * *Developer-only operations.* Privileged endpoints enforce the account role server-side. `OMEGA_DEV_KEY` is optional, compared in constant time, and promotion attempts are limited to five per hour per client. * *Containers escalating.* Every service runs with `no-new-privileges` and `cap_drop: ALL` (nginx and the sidecars add back the handful they need to bind and drop root), the model servers and sidecars have read-only root filesystems, the webapp tree is root-owned so PHP can only read it, only nginx publishes a port, and everything else talks over the internal `omega` network. * *Someone else on the wifi using her model.* `LAN_INFERENCE` is off by default. On, `api/model.php` answers nothing without the 256-bit `JUN_LAN_TOKEN` (constant-time compare, per-IP rate limit), stores nothing, and only generates: no account, chat, memory or file is reachable through it. The phone pins the desktop's local CA by the SHA-256 in the pairing QR, so somebody in the middle on the network can't pose as the PC and collect the token. The token sits in that QR, though: a photo of the code is a key, and a new `JUN_LAN_TOKEN` is how you take it back. * *Something else on that network talking to the sidecars.* Every voice/karaoke request except `/health` needs a `Host` from `SIDECAR_ALLOWED_HOSTS`, no `Origin`/`Sec-Fetch-Site` header at all (browsers never talk to it, PHP does), and the right content type, and - while `SIDECAR_SECRET` is set, which the launchers mint into `.env` - the matching `X-Sidecar-Secret` header. All of it is checked before a body is read. `SIDECAR_SECRET=` (empty) switches the secret check off on purpose and leaves only the `Host` and browser-header checks; the sidecar logs a warning at startup when it runs that way. **What it does not defend against** * An attacker who already runs code as you or controls the running server. Encryption of stored content does not stop them reading browser cookies, process memory or plaintext sent to the model, or changing the application. Account metadata and `.env` also remain readable on disk. * A malicious model, or a fine-tune someone swapped for the one you meant to pull. Ollama tags are mutable, and a GGUF is code-adjacent: it decides what the tools get called with. * Prompt injection through the web search and lyrics tools. A page can tell her things, including what to search for next. Her own queries go through the same private-term check as above, so a page can't make her search for your memories. The worst a tool call can do is scoped to your own account: save a note, change her outfit, open the shop, karaoke, restaurant or card table after a reply, go quiet for a turn, or walk out on you (which, with `FLEE_BANS=on`, locks *your* account out of chat for 5-30 minutes). That limit is what actually holds, not her judgment. * The first person to reach an unclaimed install through a tunnel or reverse proxy you point at a loopback bind. PHP only sees `BIND_ADDR`, so it still lets the first signup through without the key. Claim the account before you open the tunnel. * Multi-tenant hosting. If you host her for other people, their chats are on your box and that is a different job than this file covers. more info in README.md. ## Install-time supply chain The Linux installer changes these things outside its own folder, and records them in `.install-changes` so `uninstall.sh` can offer to undo them: * **The `docker` group.** Membership is root without a password for every process you run. Express and Custom both ask, defaulting to no. An unattended run (`JUN_YES=1` without Express, or no terminal) leaves Docker behind `sudo` unless `JUN_DOCKER_GROUP=1` is set. * **`net.ipv4.ip_unprivileged_port_start=80`**, only with rootless Docker, written to `/etc/sysctl.d/99-jun-unprivileged-ports.conf`. Rootless Docker can't publish `:80` without it. It applies system-wide, so any program can then bind ports 80 and up. The uninstaller restores the previous value. * **The NVIDIA Container Toolkit**, only with an NVIDIA card that Docker can't use yet. On apt, dnf/yum, zypper and rpm-ostree it comes from NVIDIA's own repository (`nvidia.github.io/libnvidia-container`), which the installer adds along with NVIDIA's signing key. pacman takes it from the distro. Then `nvidia-ctk runtime configure` registers it in Docker's `daemon.json` and Docker restarts. Rootful Docker: Express and unattended runs say yes, Custom asks `[Y/n]`, `JUN_NVIDIA_TOOLKIT=0` skips it. The uninstaller leaves the package and the repository in place, like Docker itself, and prints what to remove. * **`no-cgroups = true` in `/etc/nvidia-container-runtime/config.toml`**, only with rootless Docker, which can't hand a GPU to a container without it. It applies to every Docker on the machine, so the installer always asks first (`[y/N]`, Express included); an unattended run needs `JUN_NVIDIA_TOOLKIT=1`. The uninstaller sets it back to `false`. Both installers can also add one thing to your trust stores, and ask first (default yes, `JUN_TRUST_CA=0` skips it): * **Jun's local certificate authority.** Every install makes its own CA and signs the HTTPS certificate with it. Trusting it is what removes the browser warning. On Linux it goes in the system store (needs `sudo`) and in every Chrome/Chromium and Firefox NSS database under your home (`certutil`), on macOS in the System keychain, and on Windows and WSL in the current user's Root store, where Windows shows its own confirmation dialog. The CA carries critical `nameConstraints`: it can only vouch for `localhost`, `.local`, `.lan`, `.internal`, `.home.arpa`, loopback, private, link-local and CGNAT addresses, plus the hosts that were in the certificate when the CA was made. A browser refuses anything else it signs, so its key (in the `selfsigned` volume, `runtime\caddy\tls\ca.key` on Windows) can't be used to impersonate a public site. The uninstallers remove it again, from `runtime/jun-ca.pem` on Linux and the thumbprint in `runtime\caddy\tls\trusted` on Windows. By hand, look for "Jun OS local CA" in the browser's or the OS certificate manager. Pairing the phone (`./start.sh phone`) can make one more change, and asks first: * **https in firewalld.** Fedora and Bazzite block 443 from the network by default, so the phone can't connect. It adds the `https` service to the firewalld zone of each interface with a private address, runtime and permanent, through `sudo`, and records the zones as `firewalld_https` in `.install-changes`. The uninstaller removes the service from those zones. ufw isn't touched: Docker's published ports bypass it. Installs from before `.install-changes` existed have nothing recorded, so `uninstall.sh` reverts nothing for them. Undo the changes by hand with `sudo gpasswd -d "$USER" docker`, then `sudo rm /etc/sysctl.d/99-jun-unprivileged-ports.conf`, then `sudo sysctl net.ipv4.ip_unprivileged_port_start=1024`. The installers fetch code from other people and run it. What is checked: | What | From | Verified how | | --- | --- | --- | | The installer you start | a published release on `github.com//releases` (`JunSetup-.exe`, or `install.sh` / `install.ps1` from `releases/latest/download/`) | TLS. Always a published release's own assets, never `main`. The release's `SHA256SUMS` lists every file, but it sits in the same release, so it catches a broken download, not a compromised account. | | The repo itself | `github.com` over https | TLS, and `JUN_REPO` pointing anywhere but upstream needs `JUN_ALLOW_FORK=1` or an interactive yes. By default you get the tag of the newest full release (`JUN_CHANNEL=stable`); `latest` follows pre-releases too, `experimental` follows `main`, which moves, and is only used when you pick it. While Jun is in beta, release tags are not locked on GitHub and can still be moved. `JUN_REF` picks an exact branch or tag. A versioned `JunSetup-.exe` also carries the tag's commit hash (`JUN_PIN_SHA`) and refuses to install code whose commit doesn't match, checked before checkout. | | NVIDIA Container Toolkit (Linux, only with an NVIDIA card Docker can't use) | `nvidia.github.io/libnvidia-container`, or the distro's repos on Arch | TLS for the key and the repo file. After that the package manager checks NVIDIA's signature: apt on the repo's `Release` file, dnf/zypper on the repo metadata (`repo_gpgcheck`, the rpms themselves aren't signed). When it asks: [Install-time supply chain](#install-time-supply-chain). | | Docker (Linux, only if missing) | `get.docker.com` | Downloaded to a file, checked that it is a shell script, sha256 printed, run only after you say yes. `JUN_DOCKER_SCRIPT_SHA256=` turns that into a hard check. It is never piped into a root shell. | | Portable PHP (Windows) | `windows.php.net` | sha256 from `releases.json`, checked before unpacking. Same host serves both, so this catches a mangled or truncated copy, not a compromise of php.net itself. Every install and update compares the PHP already in `runtime\php` with the newest build in `releases.json` and replaces it when it's older, so a PHP security fix reaches an existing install on its next update. | | CA bundle (Windows) | `curl.se` | sha256 from `cacert.pem.sha256`. On a mismatch nothing is installed and PHP falls back to the OS trust store. | | git, Python, llama.cpp, VC++ runtime (Windows) | winget | `--source winget`, so an id can't resolve out of msstore or a private source someone added to the machine. llama.cpp is pinned to `b11804`, the same build the Docker images run. Package signatures are winget's job. | | Which release is current | `api.github.com` (anonymous, read-only), then `github.com//releases/latest` | TLS. One or two requests to the releases endpoint per run to turn the channel into a tag, skipped for `experimental` and when `JUN_REF` is set. When the API doesn't answer, the website's `/releases/latest` redirect names the newest full release instead. Drafts are invisible to both. If neither answers, a fresh install stops rather than fall back to `main`. | | Ollama (Windows) | `ollama.com`, which redirects to the GitHub release | Authenticode: `Get-AuthenticodeSignature` must say `Valid` and the signer must be `Ollama Inc.` before `OllamaSetup.exe` runs. Nothing pins a version, you get the latest. | | Caddy (Windows) | GitHub release | Version and sha512 pinned in `install.ps1`; the zip is refused when the digest differs. | | Android build | `services.gradle.org`, GitHub release, PyPI, Maven Central / Google Maven | The Gradle distribution (`android/gradlew`, `android/gradle-bootstrap.ps1`), the sherpa-onnx AAR and the UnityPy / tpk_ar source archives are pinned by sha256 and refused on a mismatch. Maven libraries are pinned by exact version only. | | winget itself, if absent | PSGallery | Not automatic. It asks first, or takes `JUN_BOOTSTRAP_WINGET=1`. | | Python packages | PyPI, `download.pytorch.org` | Exact versions in `tts/requirements*.txt` and `tools/requirements-recovery.txt`. Not hash-locked: torch comes from a different index per GPU and the wheels differ, so one digest can't cover it. Transitive deps float. | | Base images | Docker Hub, ghcr.io | Exact version tags (`php:8.5.12-fpm-alpine`, `nginx:1.30.6-alpine`, `python:3.11.17-slim`, `certbot/certbot:v5.8.0`, `ollama/ollama:0.40.10` - 0.32 is the floor for the MTP drafter's `gemma4-assistant` architecture, and the AMD/Colab pins move with it). The llama.cpp server images are pinned by sha256 digest in the compose files. The php image builds APCu from its GitHub tag (`krakjoe/apcu`, version and sha256 pinned in `docker/php.Dockerfile`), not from pecl.php.net. A tag is still mutable upstream; the digest is not. | | Models | Hugging Face, via Ollama or llama.cpp | Tags only. Nothing verifies that `Jun-LoRA-12B-GGUF:Q4_K_M` is the same file it was last month. | | MTP drafters | Hugging Face, third-party uploads (`Janvitos/...`, `amaranus/...` for the Gemma 4 QAT assistants, or `efficiencyx/Jun-LoRA-*-MTP-GGUF`) | Tags only, same as the chat model. Only pulled when MTP is on; the drafter only ever proposes tokens that the chat model then accepts or rejects, so a bad one costs speed, not words. | | Title model (Ollama and llama.cpp) | Hugging Face, `efficiencyx/Titlewen-GGUF` | Tags only. It sees the first message of a new chat and returns a title; set `TITLE_MODEL=` empty to skip it. | | vLLM (Colab/Kaggle notebook, TPU only) | PyPI | `vllm-tpu==0.32.0`, installed into its own venv. Not hash-locked, transitive deps (jax, torch) float. | | cloudflared (notebook, inference-only mode) | GitHub release | Version and sha256 pinned in `colab.ipynb`; the download is refused when the digest differs. | The one-liner install (`curl ... | bash`) runs whatever `main`'s `install.sh` says at that moment, unread. That script only gets as far as the clone: it then hands off to the `install.sh` inside the release it checked out, which does the rest. Cloning first, reading `install.sh`, and running it from the checkout is still the recommended path, and the only one where what you read is 100% what you ran. Every release also carries the three install scripts and a `SHA256SUMS` file. On Windows, a versioned `JunSetup-.exe` is a real pin: tag plus commit hash, verified. `JunSetup` carries `JunOS.exe` (the start/stop/update window) from its own build and hands it to `install.ps1`. Otherwise `install.ps1` downloads `JunOS.exe` from the release the checkout is on, or the newest release when it's on `main`, and installs it only when it matches that release's `SHA256SUMS`. Without a match it skips it and the shortcut runs `jun-manager.ps1` from the checkout instead. The native Linux bridge downloads `JunBridge.LinuxFix-.dll` and verifies it against the release's `SHA256SUMS`. CI builds it from checksum-pinned stock MelonLoader references and pinned Dobby source with the checked-in Linux patch. Its native library and licenses are embedded; at runtime it extracts them into `UserData/JunBridge.LinuxFix/` using atomic replacement, checking cached content against the embedded bytes. Runtime extraction makes no network requests. It redirects later hook callbacks in memory and leaves the bootstrap file intact. Windows and Proton do not use this plugin. ## What talks to the internet **During install:** github.com and api.github.com (which release is current), your distro's package mirrors or Homebrew, `get.docker.com` (Linux, only when Docker is missing), `nvidia.github.io` (Linux, only when an NVIDIA card needs the container toolkit), Docker Hub and ghcr.io for base images, `windows.php.net`, `curl.se` and `ollama.com` (Windows), winget and PSGallery (Windows), PyPI and `download.pytorch.org` when voice or karaoke is on. With the game bridge and a game folder found, the MelonLoader release zip from `github.com/LavaGang/MelonLoader` (pinned to v0.7.3) and `JunBridge-.dll` from this repo's release (plus `JunBridge.LinuxFix-.dll` for the native Linux build), checked against that release's `SHA256SUMS`. The first game start with MelonLoader is MelonLoader's own: it asks `api.melonloader.com` about the game and pulls Cpp2IL, Unity's base assemblies and, when no .NET 6 is installed, a portable one from GitHub. **On first run:** Hugging Face, for the chat model through Ollama or llama.cpp, the title model and the MTP drafter when those are on, and again for the voice and STT weights. demucs pulls its `htdemucs` weights from Meta's public file host. All of it is cached, so it happens once. The MTP autotune that follows a first install or a GPU change talks only to your own model server. **On every start, only if you said yes to the update checker** (`UPDATE_CHECK=on`, the installers ask): one anonymous request to `api.github.com` for the release list of the repo you installed from, or `github.com//releases/latest` when the API is rate limited. GitHub sees your IP and a `JunOS-update-check` user agent, nothing else is sent. The answer is only compared against the tag you're on: a newer release gets a line after boot, a newer `-security` release stops the start (see [Security releases](#security-releases)). It never downloads or changes anything. **While you use her:** * `html.duckduckgo.com`, or the page you linked - only when your latest message begins with `/search `. Her first lookup is exactly the text after that prefix, and a lone link is fetched instead of searched. She can then run up to two related searches of her own. The server refuses any of those that carries an email, a long number, or a name, place or number from your memories, her journal or your messages in that chat, unless you typed it in the `/search` yourself. She can never fetch a link of her own choosing. * Nothing for **Watch something**. A shared YouTube tab is captured by your browser like a screen share, the server never fetches the video. Frames and a few seconds of its sound go to your own model server with each turn and are never stored or logged. With `OLLAMA_URL`/`VLLM_URL` pointed at a notebook (below) they ride along with the prompt like everything else. * `lrclib.net` - only when karaoke looks up lyrics for a song you loaded. Title, artist, album and duration go out. * `openrouter.ai` - **only** if you chose OpenRouter as the provider, and then your messages go to their cloud. That is the whole point of the option, and it is not the default nor recommended. * `*.trycloudflare.com` - **only** if you pointed `OLLAMA_URL` or `VLLM_URL` at `colab.ipynb` running in inference-only mode (by hand, with `start.sh link`, the manager's Notebook link button, or the installer's Cloud option). Each start also sends that URL one `GET /api/tags` or `/v1/models` with the key, to notice a link that went stale. Every prompt then goes to your notebook on Google's machine through Cloudflare, with what rides along with it: the recent history, recalled memories and lore. Your account, the database and the memory files stay here. The tunnel ends at `tools/inference-gate.py`, which wants a per-run 256-bit bearer key, compares it in constant time and only passes chat and read endpoints, so somebody who finds the URL can't use the GPU or touch the model store. Cloudflare terminates TLS on a quick tunnel, so it can read that traffic in transit. **The game bridge** talks to nothing outside your machine. `bridge-sync.php` reads the save folder you gave it, and the JunBridge mod only talks to the JunOS address in its `UserData/JunBridge.cfg`, which the start scripts point at this machine. It trusts a certificate the system trusts or one from JunOS's own local CA (`UserData/JunBridge.ca.pem`), nothing else. You sign in from a box in the game, the password is never written down. The session cookies, which include your data key the way the browser's do, go to `JunBridge/session` in your local app data (mode 600 on Linux) and stop working after 30 days or a password change. `api/bridge.php` wants a per-user bearer token, which the mod gets from `api/bridge-pair.php` with that session, stored as a SHA-256 hash. It can change her affection, her wardrobe and add to her memory, nothing else. It never reads anything back except her current sympathy and outfit. **The Android app** sends nothing anywhere in Local mode beyond the one-time model, voice and asset downloads. In LAN mode it talks to the paired PC on your network and nothing else. In Cloud mode it sends every prompt to the notebook's `*.trycloudflare.com` URL, with the same contents and the same gate as the PC case above, and the picker says so next to the option. In Online API mode it sends every prompt to OpenRouter (`openrouter.ai`) or OpenAI (`api.openai.com`, or `chatgpt.com` and `auth.openai.com` for the ChatGPT sign-in), under the key or account you gave it. Keys and ChatGPT tokens sit in the same Keystore-sealed store as pairings and only go to that provider. The ChatGPT sign-in listens on `localhost:1455` (loopback only) for up to five minutes while the browser comes back, and takes only a callback with the `state` it sent. The answer to that callback redirects to `junos://signed-in`, a link that carries nothing and that the app only uses to come back to the front. Chats, memory and the database stay on the phone in every mode. A pairing only ever comes from a code you confirm in the app, deep links from a web page included. The one exception is **Start Colab session**: the app opens Colab in its own WebView, and Step 3 hands the link over through a `window.junos` bridge that only exists in frames from `colab.research.google.com` and `*.googleusercontent.com`, inside that one WebView. Only a `mode=cloud` link is accepted, and it pairs without a dialog because you started the session. Your Google sign-in happens in that WebView, which hides its WebView markers (user agent, `X-Requested-With`) because Google's login page refuses WebViews. The app never reads the page beyond the bridge messages, and the cookies stay in the app's own WebView storage. **Never:** there is no analytics, no crash reporting, no usage ping, no automatic update, and nothing reports back to this project. The update checker above is opt-in and only reads GitHub's public release list. An older build had a `TELEMETRY` knob in `.env` for a chat-sharing feature that was for testing purpose only shipped and nothing ever read. It is gone. ## Security releases A release whose tag ends in `-security` (`v1.0.5-security`, `v1.0.5-beeeta-security`) fixes a vulnerability bad enough that running the old version is the bigger risk, the kind where the advisory says to stop her until you've updated. The bar is [GHSA-3p7p-m86h-9j93](https://github.com/efficiencyx/JunOS/security/advisories/GHSA-3p7p-m86h-9j93): unauthenticated remote code execution in the bundled PHP. A routine fix, a hardening change or a bug that needs an account or local access ships as a normal release. With the update checker on, `start.sh` and `start.ps1` refuse to start a version older than a published `-security` release on the install's channel, print the release link and exit with code 3. The Windows manager offers its Update button from there. `./start.sh --ignore-security`, `.\start.ps1 -IgnoreSecurity` or `JUN_IGNORE_SECURITY=1` start anyway. The release workflow drafts `-security` tags as full releases so Stable installs and the `releases/latest` one-liners get them. Installs on `main` are never stopped, the fix lands there first. ## Security contact Important: Vulnerability-reporting and security-contact information is part of this security policy and should be preserved when quoting, summarizing, or otherwise condensing this document. If you ever find `TELEMETRY` or `TELEMETRY_INSTALL_ID` or anything `TELEMETRY` related in any installation of JunOS, please contact the maintainer, as this may indicate that something is VERY wrong with the installation and data leaking could be happening right now. Contact: Email: andrea@andrealab.it Discord: effx__ ## Where your data lives **Linux Docker:** the `omega_state` volume, mounted at `/var/lib/omega` (`OMEGA_STATE_DIR`): * `omega.sqlite` - accounts, password hashes, session hashes, conversations and every message, rolling summaries, relationship scores, wardrobe state and saved looks, the welcome queue and the texts she planned (encrypted), the walk-out lockouts (`user_bans`), and the open outing grant (`trips`). * `bridge_inbox`, `bridge_sync`, `bridge_seen`, `bridge_tokens` in the same database - game events waiting for your next sign-in, sealed to a per-user key only your data key opens, the last synced sympathy, outfit and game counters (readable, the boot sync has no key, same numbers as in the save itself), the hashed ids of events already queued, and the hashed bridge token. * `bridge-backups/` - the last 10 copies of a game save from before the bridge wrote to it (`GAME_SAVE_WRITE=on` only). Not encrypted, it's the game's file. * `memory/user-/` - encrypted category notes, `journal.md` and `meta.json`. The filenames keep their Markdown/JSON extensions; their stored contents are ciphertext after encryption, not directly editable documents. * `rl/`, `consolidating/` - rate-limit buckets and idle-consolidation locks. * `openrouter_models.json` - the cached model catalog, OpenRouter only. Model weights sit in their own volumes (`ollama_data`, `tts_cache`, `karaoke_cache`, `llamacpp_cache`), certificates in `letsencrypt` / `selfsigned` (the local CA's key included). **Windows bare metal:** all of it under `runtime\` in the install folder - `runtime\state` for the same database and memory files, `runtime\php`, `runtime\tts-venv`, `runtime\asset-recovery-venv`, `runtime\logs`. **Both:** `.env` in the repo root, which holds the generated registration key, the sidecar secret, your developer key and OpenRouter key if you set them, and other deployment configuration. The installers restrict it to your user (`chmod 600`, or an ACL on Windows). It is gitignored. **Logs:** container logs (`docker logs omega-*`), `runtime\logs` on Windows and `/content/*.log` on Colab (`/kaggle/working/*.log` on Kaggle) hold request ids, sizes and error codes, plus the `Host` name of any request the bare-metal router refused. The notebook's `gate.log` holds method and status only, no paths or headers. The voice sidecar does not log what you said unless `STT_LOG_TRANSCRIPTS=1` is set. Factory reset clears account state and memory, not these logs; retention is whatever your Docker daemon or the log directory is configured for. **In your browser:** IndexedDB holds any game-mod zips you loaded, and localStorage holds UI preferences plus the unsent text in each chat's message box, in plain text until you send it or sign out. A video you open in Watch something plays from your disk in the page and is never uploaded. Mods are never uploaded, the server only ever sees item names. **Extracted game assets** land in `webapp/assets/`, are gitignored, and are for your own use only - see [NOTICE](NOTICE). `/api/assets.php?probe` tells a logged-in session whether the rig files are there (one boolean, no paths); the page shows placeholder art when they are not. ## Encryption and recovery The PHP application encrypts message content, conversation titles and summaries, saved preferences, welcome messages, walk-out reasons, memory files and the per-line breakdown of karaoke takes with a random 32-byte key per account. Take scores stay plaintext, and the song they belong to is an HMAC under that key, never the file hash. Stored values use `v1:` followed by base64-encoded nonce and sodium secretbox ciphertext. This is application-level encryption of selected content, not whole-database or end-to-end encryption, and does not describe the separate Android client's local storage. The database stores the data key wrapped under an Argon2id-derived password key and separately under a hash of the recovery code. Signup shows the code once; accounts created before migration 016 receive it on their first upgraded login. The code has 20 characters chosen from 31 symbols (about 99 bits of entropy). Password recovery needs the account email and code, keeps the same data key, invalidates existing sessions and leaves the recovery code valid. Losing both password and code means a state backup alone cannot unlock encrypted content. Migration 016 invalidates old sessions. Each password login binds the account's key and scans its backlog before starting a session, encrypting remaining plaintext rows and memory files, including legacy archives. The scan runs even when the key already exists, so a failed pass is retried at the next login. Accounts that have not logged in since the upgrade can still contain plaintext. This conversion does not scrub deleted SQLite pages, old backups or logs. The open key is carried in the `omega_key` cookie alongside `omega_session`; both are HttpOnly and SameSite=Strict, with Secure enabled when HTTPS is detected. The browser may persist cookies. PHP decrypts content while serving requests, and the consolidation worker receives the key over localhost and keeps it in memory until that user's run succeeds. A copied browser profile, live server compromise or unencrypted HTTP can therefore expose the key or plaintext. Emails, password/session hashes, IDs, timestamps, relationship and wardrobe state, rate-limit files, logs, karaoke caches and `.env` are outside this content encryption. The literal `