NexusIRL is a free, open-source IRL live streaming app for Android: SRTLA bonding of mobile data and Wi-Fi, adaptive bitrate, SRT and RTMP, Twitch and Kick chat, OBS remote control. An Android port of Moblin.
Each threat is mapped to the guardrails in PORT_PLAN.md §9 and to how they are verified. Status shows which guardrails are implemented so far.
streamid/passphrase and WHIP bearer headers.| # | Threat | Guardrails | Verification | Status |
|---|---|---|---|---|
| T1 | Device theft or file extraction: an attacker with a rooted device or an adb backup reads stored settings | Secrets live in SecretStore (Tink AES-256-GCM, keyset wrapped by a non-exportable Keystore key). Settings hold only {secret:…} references. Backups and device transfer are disabled. |
AeadSecretStoreTest (no plaintext on disk, a wrong key can’t decrypt), plus an instrumented adb pull + grep check |
SecretStore, backup rules, settings splitting (SettingsStoreTest; verified on the emulator with run-as cat): done |
| T2 | Shoulder-surfing, screenshots, screen recording, casting or the recents thumbnail leak a key | Masked fields with a reveal toggle, FLAG_SECURE on secret screens, the overlay shows only stream name and host, optional biometric reveal |
Instrumented FLAG_SECURE test | Masked fields, FLAG_SECURE, host-only display: done. Copied secrets (the web remote control link) are marked sensitive and cleared from the clipboard after 60 s unless something else was copied since (SensitiveClipboardTest): done. Biometric reveal: done: in the stream editor a saved stream key, multi-stream URL or Discord webhook can be shown or changed only after the fingerprint, face (strong biometrics) or screen lock, once per visit, with the system BiometricPrompt (no Play services); without a screen lock it is shown without asking and the user is told to set one; streaming never asks (SecretUnlockTest) |
| T3 | Logs or diagnostics leak secrets: logcat, the in-app log, remote-control log events, log export |
Redactor on every sink: URL reduction, credential patterns, exact match on stored values. No telemetry. |
RedactorTest, DiagnosticsTest |
Redactor: done. Stream status messages: done. Diagnostics report (the only way logs leave the app; shared by the user after a warning, never sent automatically): done, checked on a phone with a marker stream key |
| T4 | Hostile LAN (venue or hotel Wi-Fi) controls the stream through the web remote control, which is unauthenticated upstream | All local servers off by default and bound to LAN only. Random access token for web control, embedded in the generated config.mjs. Lockout after wrong passwords. |
System tests: no token gives 401/close; lockout triggers | done: web control needs a 128-bit token (link sets an HttpOnly SameSite=Strict cookie; WebSocket pages without it are closed before anything is sent), “New link” revokes all browsers, LAN/hotspot/Tailscale only and never mobile data (RemoteControlWebTest; checked in a real browser on the emulator) |
| T5 | A sniffed remote-control session allows offline brute force of the password hash | Strong generated default passwords, a minimum strength rule, and a recommendation to use wss:// relay or Tailscale over the internet. The protocol stays upstream’s for iOS interop. |
Unit test of the password policy | Streamer side and Moblink relay: done (passwords in the secret store, no “1234” default for Moblink, strong random default, plain ws:// refused outside LAN/Tailscale addresses, warning in the UI). Assistant server: done (LAN/hotspot/Tailscale only, never mobile data; 5 wrong passwords lock an address out for 60 s; RemoteControlAssistantTest). Web control token: done (T4) |
| T6 | A remote assistant overwrites settings (importSettings) or redirects the stream |
Dangerous requests are disabled by default and need confirmation on the device | System test | done: importSettings from an assistant is refused unless “Allow settings import from the assistant” is on (off by default; imports and exports never change it). When on, the phone shows what will be replaced (stream names, “contains stream keys”, never the keys) and imports only on Accept; it is rejected after 30 s, when the stream goes live, when the connection drops and while another is waiting. Other requests keep being answered meanwhile. Web pages never import (RemoteImportPolicyTest, RemoteControlTest, SettingsStoreTest). setStream stays as upstream without a question: it only picks one of the streams already saved on the phone, and only while not live, so it cannot point the stream at a new server. WHIP is not supported on Android |
| T7 | A malicious deep link, QR code or settings file silently changes stream targets or starts streaming | Treated as untrusted input: strict validation, a diff-style confirmation, never auto-start, rejected while live | System test | Settings files and moblin:// links: done (validated like upstream, confirmation showing hosts only, rejected while live, never auto-starts; OBS passwords, remote control and Moblin Cloud in links are ignored). QR scanning: later |
| T8 | A key sent in cleartext on the network (RTMP, SRT without a passphrase) | “Unencrypted” badge in the UI, RTMPS suggested, SRT AES encryption implemented in the Kotlin sender, full TLS verification | SRT KM interop test, packet capture | SRT AES-128/256 encryption, RTMPS hostname verification: done (live libsrt interop and wire tap). RTMP cleartext warning in the stream editor: done |
| T9 | A malicious browser-widget page reads app data | The moblin.js bridge exposes only chat and speech-to-text, no file or content access |
Unit test of the bridge surface | Browser widgets: done (https only, no file/content access, moblin object only with Moblin access, no settings or secrets exposed; chat only to pages that subscribe, in upstream’s format) |
| T10 | Token over-privilege | Minimal, incremental OAuth scopes and revoke on sign-out | Review | Twitch: done (11 scopes instead of 28, revoked at Twitch on sign-out, tokens only in the secret store and never exported). Kick: accepted risk: Kick’s website session has full account access and cannot be scoped or revoked by the app (Kick’s OAuth needs a client secret); it is kept only in the secret store, never exported, the sign-in page runs in its own process whose web data is deleted after sign-in, and a session Kick rejects is deleted. YouTube: later |
| T11 | Supply chain: a compromised dependency or a leaked secret in the repo | Version catalog pinning, dependency verification, CodeQL and gitleaks in CI, signed releases with checksums | CI | gitleaks, CodeQL and Gradle dependency verification in CI: done. GitHub Actions pinned to commits, including the release job that holds the signing key: done (2026-10-31). Open: no scanner yet checks the Gradle dependencies for known vulnerabilities (Dependabot only covers the actions); until then, the monthly update review in docs/RELEASING.md |
| T12 | Dependence on third-party services run by the upstream author | No author-run endpoints. CI check for author hosts. | tools/check-independence.sh in CI |
done |
| T13 | The stream reveals where the streamer lives through map or location widgets | Location is off by default and stays on the device. Privacy regions hide the position on maps while inside them; their coordinates are never shown on screen. | SettingsLocationTest |
Location, privacy regions, map widget hidden inside them: done (checked on the emulator) |
| T14 | A settings export with stream keys is copied off the phone (cloud storage, a shared folder, a lost USB stick) | Exports leave keys out by default. With keys, the default is an encrypted file: AES-256-GCM with a key from PBKDF2-HMAC-SHA256 (600,000 iterations) of a passphrase of at least 10 characters, the header authenticated, iteration counts capped on import. A plain export (needed for iOS Moblin) only after a warning. | EncryptedSettingsTest (round trip, wrong passphrase, tampering, hostile iteration count); export and import checked on the emulator |
done |