Skip to content
OpenTuwaPublic

About

Covert spying and privacy-first messaging and walkie talkie without internet broadband

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

LanMesh — Full Blueprint

Offline, encrypted group chat + walkie-talkie for one WiFi / hotspot / LAN. No accounts. No servers. No internet. If you are on the network, you are in the room; if you are off it, you receive literally nothing — there is no server to subpoena, no cloud to leak, no packets to intercept.

License: MIT — see LICENSE. Copyright © 2026 OpenTuwa.com (https://opentuwa.com).

This document is the complete technical blueprint: what the system is, why it exists, every feature, how to use it, the full architecture, every runtime flow, the wire protocol byte-for-byte, and how it is built and tested.


1. WHAT IS THIS

LanMesh is a single-room group messenger with push-to-talk voice, scoped to a local IP network:

  • Transport: WiFi infrastructure mode, phone hotspot, or any L2/L3 LAN. All sockets bind to the LAN only (UDP broadcast, LAN TCP). The app contains zero cloud URLs, zero accounts, zero analytics, zero push-notification services.
  • Chat: everyone connected sees every message (group chat only, no DMs).
  • Voice: half-duplex walkie-talkie — hold TALK to speak, everyone hears.
  • Identity: per-device Ed25519/X25519 keypair generated on first launch + a user-chosen cover name (display only, changeable anytime).
  • Crypto: AES-256-GCM for chat and voice. Room keys are derived on-device; ciphertext is all that ever traverses the network.
  • Reliability: a foreground service keeps the listener alive with the screen off; every phone stores every message and re-serves missed ones to peers that were dozing (store-and-forward anti-entropy sync). Nothing is lost to idle gaps.

Non-goals (deliberate): no internet messaging, no multi-room, no DMs, no message editing/deletion sync, no always-on full-duplex calling, no iOS yet.


2. WHAT USE / WHY

The problem

Protests, classrooms, dorms, offices, travel groups, game nights, emergency coordination: people in one place need to talk now, privately, without:

  1. exchanging phone numbers or creating accounts,
  2. depending on cell coverage / venue WiFi internet (captive portals, congestion, shutdowns — the radio works even when the uplink doesn't),
  3. routing sensitive chatter through a third-party server that logs, scans, throttles, or gets breached.

Existing apps fail at least one of these: they need sign-up, need internet, or store plaintext/metadata server-side.

Why the LAN is enough (the capacity argument)

A voice stream at 32 kbps ≈ 200 bytes × 50 packets/s ≈ 16 KB/s. The PCM fallback (no compression at all) is ~96 KB/s. A game download is 10–50 MB/s — three orders of magnitude more. If a router can move a game download, voice is a rounding error; fibre uplink speed is simply irrelevant because these packets never go near the ISP: phone → local radio → phone, at ~2–10 ms latency (better than any internet call). No "VoIP server", no SIP/PBX, no WebRTC (which exists for NAT traversal across the internet and is pointless on a LAN) is needed. This is the entire technical bet of the project, and it is just arithmetic.

Why not Bluetooth?

Range (~10 m, pairing friction, piconet limits) vs WiFi (whole building, no pairing, every phone already joined). WiFi wins on every axis that matters here.


3. TRUST MODEL & CRYPTO DESIGN

Rule: same network = same room. Anyone on the LAN with the app reads the room; anyone off the LAN gets nothing to decrypt. There is no per-user verification in v1 (no QR, no passwords) — a conscious product decision, not a missing feature. The QR/SAS verification code exists in the core as the upgrade path for closed rooms.

Key schedule (all deterministic, zero key-exchange traffic)

group_id = hex( blake3(b"lanmesh-default-room-v1")[0..16] )   # store.rs:43
chat_key = blake3("lanmesh-open-chat-v1:<group_id>")          # group_crypto.rs:55
talk_key = blake3("lanmesh-open-talk-v1:<group_id>")          # group_crypto.rs:56
peer_id  = hex( blake3(ed25519_pubkey)[0..8] )                # identity.rs:54
  • Every phone independently derives identical chat_key/talk_key, so the room converges with zero setup and zero key-distribution packets.
  • Long-term identity: Ed25519 signing key (stored identity_ed25519.key, mode 0600) + X25519 DH key derived from the Ed25519 seed via HKDF-SHA256 ("lanmesh-x25519-v1"), so only one secret is stored.
  • LanMesh::new loads the group snapshot and injects talk_key into the PTT engine (lib.rs) — without this the engine keeps its all-zero key and drops all voice by design ("not joined yet", ptt.rs:163).

Chat frame (ChatMessage, gossip.rs)

{ msg_id: uuid-v4, sender_id: peer_id, sender_name: cover (display only),
  lamport: u64 clock, ts_ms, nonce_ct_b64, sig_b64 }
nonce_ct_b64 = base64( epoch_be32 || nonce12 || AES-256-GCM(chat_key, plaintext) )
sig_b64      = Ed25519( msg_id | lamport | nonce_ct_b64 )
  • Random 12-byte nonce per message; epoch selects current vs history key (8-key window, rotate_all, kept for future rotations).
  • Merge rule (GossipEngine::merge): dedupe by msg_id; verify Ed25519 if the sender's key is known, accept otherwise (open room); forged known-sender frames are dropped. Returns "was NEW" → drives notify + re-gossip.

Voice datagram (ptt.rs, 18-byte header + ciphertext)

[0]     version = 0x01
[1]     type: 0x10 FLOOR_REQ / 0x11 FLOOR_GRANT / 0x12 FLOOR_RELEASE / 0x20 VOICE
[2..10] sender peer_id (8 raw bytes)
[10..14] seq u32 BE (per-talk counter)
[14..18] talk_id u32 BE (bumps per button press; receiver resets on change)
[18..]  AES-256-GCM(talk_key, nonce = sender[8] || seq[4], voice_bytes)
  • Nonce is derived, not transmitted (saves 12 B). No-AAD encryption; decrypt accepts AAD-bound or plain (interoperability-tolerant).
  • Own-echo suppression by peer_id; all-zero key drops (unjoined).
  • Sender: AudioRecord, VOICE_COMMUNICATION source (falls back to MIC), 48 kHz mono s16, exact 960-sample reads; RMS voice gate at 150 (room silence never hits the radio); playback muted while transmitting.
  • Receiver: AudioTrack, USAGE_VOICE_COMMUNICATION + CONTENT_TYPE_SPEECH, low-latency mode, loudspeaker forced while Hear is on (prior state restored after); frames dropped (not queued) while talking or muted.

4. FEATURES (complete)

Chat

  • Open-room group chat: everyone on the LAN in one room, chronological rows, own messages right, others left with cover name + HH:mm time.
  • Cover name: onboarding (2–24 chars), ⋮ → Change cover name anytime; beacons and outgoing frames update immediately.
  • Empty state: "Nothing here yet…" placeholder when history is empty.
  • Panic wipe: ⋮ → Erase my messages deletes local history instantly (other phones keep theirs).

Voice (walkie-talkie)

  • HOLD TO TALK press-and-hold (orange glow + TALKING… release to stop).
  • Hear ON/OFF toggle (grays out when off); loudspeaker forced while on.
  • Half-duplex: playback muted while transmitting — echo is impossible.
  • Voice-activity gate (RMS < 150 skips silence); mic fallbacks (VOICE_COMMUNICATION → MIC); voice can never crash chat (guarded init).
  • Dual send: multicast + per-peer unicast (hotspot-proof); live talker id in the status line; tx/rx/drop counters in ⋮ → About.

Reliability & alerts

  • Foreground service + WifiLock + MulticastLock; ongoing notification (LanMesh • cover / N people on this WiFi).
  • High-priority message notifications (single or 5-line inbox summary, 8 rotating ids, tap opens chat, no repeats via last_notified_ts).
  • Catch-up sync: anything missed while dozing arrives and notifies on wake.
  • Auto-restart after reboot (only if the user joined before).

Guidance & consent UX

  • First-run coach (Hear+volume / one-talker / same-WiFi rules; ⋮ → How to use reopens; shown once via the coach_v1 flag).
  • Stubborn-denial notices on every attempt: mic blocked → Settings; notifications off → Turn on (message still sends — warn, never block).
  • Notification permission asked at launch; mic asked on first TALK press; battery-unrestricted tip lives in ⋮ → Keep alive in background.

5. HOW TO USE

Install

  1. Uninstall any older LanMesh (old installs predate permission fixes and crash-loop on start — always reproduce with a clean install).
  2. adb install -r android/app/build/outputs/apk/debug/app-debug.apk (≈10 MB, signed, installable). Release (app-release-unsigned.apk) needs a keystore first.

Join & chat

  1. All phones on the same WiFi/hotspot. 2. Open → cover name → Start chatting through the coach. 3. Type → Send. Subtitle shows the peer count; the status line shows listener + voice state.

Talk

  1. Hear ON + volume UP. 2. Hold TALK, speak, release. 3. One talker at a time — two pressing at once hear nothing; wait for silence.

Permissions (chat never depends on mic)

  • Notifications (Android 13+): reply alerts; denied → notice on every send.
  • Microphone: asked on first TALK; denied → notice on every press.
  • Battery unrestricted (⋮ → Keep alive…): recommended for instant screen-off alerts; without it Doze delays delivery until wake (sync heals).

Troubleshooting

Symptom Cause → fix
Crash right after choosing name (old builds) stale APK → uninstall + install current
Crash at service start on Android 14+ mic FGS type without grant — fixed: service starts chat-only, upgrades on TALK
Nobody appears different SSID / AP-client isolation → same network
Can't hear voice Hear OFF or volume down → toggle + volume up
TALK silent for others mic denied (notice shows) or genuine silence gated by VAD
No alerts with screen off battery restricted / notifications off → menu + notice shortcuts

6. ARCHITECTURE

Layers (strict — UI never touches sockets or crypto directly):

MainActivity / ChatAdapter / BootReceiver   (UI + permission UX)
MeshService  (foreground: node owner, beacons, TCP gossip, sync, notify, locks)
PttAudio     (mic/track/UDP :7899, VAD, speaker routing) | MeshState (observable)
LanMesh.kt ── JNI (com/lanmesh/app/LanMesh, JNI_OnLoad) ──▶ Rust core
Rust core    (identity, crypto, gossip store, PTT engine) ──▶ LAN ONLY

Rust core (core/src, shared by Android/JNI, the C ABI and desktop):

  • lib.rs — LanMesh handle (Arc<Inner{identity, group RwLock, store, gossip, ptt}>); injects talk_key into PTT at init.
  • ffi.rs — node_* logic shared by JNI + C ABI (incl. chat_export).
  • jni_bridge.rs — the 20 natives (table below).
  • identity.rs — Ed25519 + HKDF-derived X25519, peer_id, b64 helpers.
  • group_crypto.rs — deterministic room keys, AES-GCM chat, 8-key history, SAS/QR helpers for future closed rooms.
  • gossip.rs — compose / merge / diff / hello / decrypt (the verified anti-entropy core).
  • transport.rs — ports, Beacon, UDP broadcast loop + listener, optional mDNS feature.
  • store.rs — file KV + per-message JSON, lamport clock, vclock, wipe.
  • ptt.rs — voice engine: frame format, floor signals, jitter-ready decrypt, mic-frame sender with VAD.
  • codec.rs — VoiceCodec trait; PCM passthrough default, Opus behind --features opus.
  • portal.rs — :8080 zero-install web portal (core-complete, no UI yet).
  • file_share.rs — chunked resumable E2EE file design (UI pending).
  • platform.rs — PlatformCallbacks OS boundary. tests/ — on-device crypto tests (see §9).

Android shell (android/app/src/main/…/com/lanmesh/app/):

  • MeshService.kt — service + chat networking + notify + locks + voice lifecycle + FGS mic upgrade. PttAudio.kt — voice client.
  • MainActivity.kt — onboarding, list, talk/hear, coach, snackbars, menus.
  • MeshState.kt — process-wide observable. ChatAdapter.kt — diffed list, scrolls only on growth. BootReceiver.kt — reboot restart. LanMesh.kt — JNI facade (signatures must match the table below).
  • Manifest: 12 permissions; service connectedDevice|microphone; boot rx.
Permission If denied / off
POST_NOTIFICATIONS (13+) chat works; notice on every send; no alerts
RECORD_AUDIO chat works; notice on every TALK; voice out dead, hear fine
Normal (INTERNET, WIFI_*, FOREGROUND_SERVICE*, WAKE_LOCK, …) granted at install
Battery unrestricted (a setting) delayed background delivery; sync heals on wake

JNI bridge (the exact contract)

LanMesh.kt (object LanMesh, System.loadLibrary("lanmesh_core")) must match core/src/jni_bridge.rs, which registers natives on com/lanmesh/app/LanMesh in JNI_OnLoad (no name mangling):

Kotlin external fun Signature Rust impl
lanmesh_new(cover, dir) (String,String):Long create node → handle, -1 on error
lanmesh_cover_name(h) (Long):String stored cover
lanmesh_set_cover(h, name) (Long,String):Int 1 = ok
lanmesh_panic_wipe(h) (Long):Int delete msgs + snapshot
lanmesh_peer_id(h) (Long):String peer_id hex
lanmesh_qr_payload(h) (Long):String lanmesh://v1/… (unused by UI)
lanmesh_chat_send(h, cover, text) (Long,String,String):String ChatMessage JSON, "" on failure
lanmesh_chat_recv(h, json) (Long,String):Int 1 = NEW
lanmesh_chat_list(h, limit) (Long,Int):String [{id,from,name,ts,text}], text nullable
lanmesh_chat_export(h, limit) (Long,Int):String raw frames for sync
lanmesh_beacon_json(h, cover, port) (Long,String,Int):String discovery beacon
lanmesh_ptt_start(h) / lanmesh_ptt_stop(h) (Long):Int / (Long):Unit begin/end talk
lanmesh_ptt_encrypt(h, pcm1920) (Long,ByteArray):ByteArray? voice datagram or null
lanmesh_ptt_decrypt(h, dgram) (Long,ByteArray):ByteArray? 1920 B PCM or null
lanmesh_file_wrap/unwrap, lanmesh_sas_for_qr, lanmesh_portal_start, lanmesh_free_str various core capabilities, no UI yet

Rust bool crosses as Int (0/1). blocking_read/write on the group lock is safe because JNI calls arrive on plain (non-async) threads.

On-disk storage (filesDir/lanmesh/, app-private, never backed up)

identity_ed25519.key   long-term signing secret (0600 on unix)
cover_name.kv          current cover name
group_id.kv            room id (deterministic)
group_snapshot.kv      GroupSession JSON (keys, epoch, peer cache)
msgs/<msg_id>.json     one ChatMessage per file
lamport.kv             clock high-water mark

Android SharedPreferences (lanmesh): cover, hear_enabled (default true), last_notified_ts, coach_v1 (coach acknowledged).

Threading (Android)

UI thread: activity + MeshState listeners. ioPool (single): send, cover, wipe, talk/hear toggles, FGS mic upgrade. netPool (cached): one reader per TCP socket + dial tasks. Dedicated daemons: beacon-tx, beacon-rx, tcp-server, sweep, ptt-rx, ptt-tx. MeshState (messages, peers, status, pttLine, myPeerId) is the only cross-thread state, emitted to main via Handler.


7. RUNTIME FLOWS

Startup / join

MainActivity.onCreate → cover? ──no──▶ onboarding (name, 2–24 chars)
                            ──yes──▶ startForegroundService(MeshService)
MeshService.onCreate:
  prefs cover → lanmesh_new(cover, filesDir/lanmesh)   # identity+store+group
  → myPeerId, own group_id (parsed from own beacon)
  → WifiLock FULL_HIGH_PERF + MulticastLock (both non-refcounted)
  → notification channels (lanmesh_svc LOW, lanmesh_msg HIGH)
  → startForeground(CONNECTED_DEVICE only — mic type added later, §5.14)
  → PttAudio(handle, hear=prefs) guarded by runCatching (voice can never kill chat)
  → beacon-tx / beacon-rx / tcp-server / sweep threads
  → refreshChat() → MeshState → UI
BOOT_COMPLETED → BootReceiver → service restarts only if a cover was saved.

Discovery (UDP :7879)

  • TX every 3 s: lanmesh_beacon_json → 255.255.255.255:7879.
  • RX: parse; drop own peer_id; drop foreign group_id; record PeerInfo(peerId, cover, ip, now) → ensureConnected(ip, tcp_port).
  • Dial rule: one socket per peer (putIfAbsent; loser closes). Sweeper prunes peers unseen for 15 s and dead sockets, re-syncs every 5 s, refreshes the ongoing notification (N people on this WiFi).

Chat send (sendChat)

lanmesh_chat_send → ChatMessage JSON (stored by Rust)
→ fanout {"t":"msg"} to all peers → refreshChat() (own row, no notify)

Chat receive / re-gossip (onIncomingList, synchronized)

chat_recv each frame → collect NEW ids
→ forward each raw frame to all OTHER peers (exactly once)
→ refreshChat() → notifyFor(newIds from others with ts > last_notified_ts)

Catch-up sync (the idle guarantee)

Both sides of every socket exchange sync_req{from, ids[]} immediately; onSyncReq replies with owned frames the peer lacks, chunked at 300 (sync_res{msgs[]}). Sweeper re-requests every 5 s, so anything missed while Doze-frozen arrives on wake and notifies then. Verified: message sent while a peer was disconnected was delivered on reconnect with full history.

Voice TX (hold TALK)

ACTION_DOWN → (mic permission? else request + notice, abort)
→ upgrade FGS to +MICROPHONE (main thread, ≤3 s) → ptt_start → startRecording
→ mute track → loop: read 960 samples → RMS ≥ 150?
  yes → ptt_encrypt → multicast 239.19.84.12:7899 + unicast to each peer ip:7899
  no  → skip frame
ACTION_UP → ptt_stop → stop mic → resume track → log micFrames/voicedFrames

Voice RX

MulticastSocket(:7899, joined 239.19.84.12) receives multicast AND unicast
→ if !hear or talking: drop → ptt_decrypt → valid 1920 B? play : drop++
→ first frame logs; talker id (header bytes 2..10) shown in status on change

Notifications

  • lanmesh_svc (LOW, ongoing, silent): LanMesh • <cover> / N people on this WiFi. Required by the foreground service.
  • lanmesh_msg (HIGH, sound+vibration, auto-cancel): single (name: text) or InboxStyle (N new messages, last 5 lines), 8 rotating ids, opens chat on tap. last_notified_ts prevents repeats.

Death / restart

START_STICKY + persisted store: process death loses nothing (history is on disk, peers re-sync on rediscovery). panic_wipe deletes local history instantly; others keep theirs.


8. WIRE PROTOCOL (exact)

Use Socket Value
Chat gossip + sync TCP 7878
Discovery beacons UDP broadcast 7879 → 255.255.255.255
Voice UDP multicast + unicast 7899, group 239.19.84.12
Zero-install portal (core only) TCP 8080

Beacon JSON: {peer_id, cover_name, sign_pk_b64, dh_pk_b64, tcp_port, group_id, epoch}. TCP frames are JSON + \n: sync_req{from, ids[]} → sync_res{msgs[]}, plus live msg{msg}. Chat ciphertext: epoch_be32 || nonce12 || AES-GCM (base64). Voice: §3 header + AES-GCM (nonce derived, never sent).

9. BUILD, TEST & REPRODUCIBILITY

Toolchain pins: NDK 28.2.13676358 (API 26 = minSdk 26), AGP 8.13.2, Gradle 8.13, Kotlin 2.0.21, JDK 17, compile/targetSdk 36. NDK linker paths live in .cargo/config.toml (edit the 4 absolute paths if your SDK is elsewhere); first launch needs local.properties with sdk.dir (Android Studio generates it).

# Rust core check + Android libs (release)
cargo check -p lanmesh-core
cargo build --release --target aarch64-linux-android \
  --target armv7-linux-androideabi --target x86_64-linux-android
# copy each target/<triple>/release/liblanmesh_core.so into
# android/app/src/main/jniLibs/<arm64-v8a|armeabi-v7a|x86_64>/
cd android && gradlew assembleDebug assembleRelease

Current artifacts: app-debug.apk ≈ 10.1 MB (signed, installable), app-release-unsigned.apk ≈ 9.1 MB (needs a keystore).

Verification matrix (all executed, all passing):

  1. cargo check + cargo check --tests — core + tests type-check.
  2. Rust unit tests run on-device (ptt_roundtrip, 4/4): voice roundtrip, wrong-key rejection, chat encrypt/rotate/history, QR SAS — executed via pushed x86_64 test binary with TMPDIR=/data/local/tmp.
  3. ci/lanmesh_peer_test.py (needs blake3, pycryptodome; adb forward tcp:7878 tcp:7878; set ANDROID_SERIAL with several devices): handshake → live app→peer fan-out decrypted with independently derived keys → peer→app merge/persist/notify → disconnect, send, reconnect → missed message + history re-synced. ALL PASS.
  4. Voice RX on-device: desktop/src/bin/voice_probe.rs (own node id, sine frames) pushed to emulator → app logs first voice frame played (1920B), talker id appears in status.
  5. Voice TX mic: 3 s TALK hold → talk start … talk stop, micFrames=137 voicedFrames=0 (~49 frames/s real reads; 0 voiced = emulator digital silence correctly gated).
  6. Crash regression: mic-revoked cold start (the Android-14 FGS/mic kill) — service starts clean; grant + TALK upgrades FGS types without error.
  7. Screenshots verified: onboarding-less join, coach sheet, chat with both sides, denial Snackbar, empty state path.

Emulator limits (stated, not hidden): emulator mic is silence (TX content unprovable there — real phones prove it); adb forward cannot do UDP, hence the on-device probe; emulator WiFi is simulated, so phone↔phone discovery is confirmed by design + code path, to be field-confirmed on hardware.

10. REPO MAP

README.md · LICENSE (MIT, © 2026 OpenTuwa.com) · .gitignore
Cargo.toml (workspace) · Cargo.lock · .cargo/config.toml (NDK)
core/src/  lib.rs ffi.rs jni_bridge.rs identity.rs group_crypto.rs
           gossip.rs transport.rs store.rs ptt.rs codec.rs portal.rs
           file_share.rs platform.rs  (+ tests/ptt_roundtrip.rs)
android/   settings.gradle.kts build.gradle.kts gradle.properties
           local.properties (local only, git-ignored) gradle/wrapper/
           app/build.gradle.kts app/proguard-rules.pro
           app/src/main/AndroidManifest.xml
           app/src/main/java/com/lanmesh/app/
             LanMesh.kt MeshState.kt MeshService.kt PttAudio.kt
             MainActivity.kt ChatAdapter.kt BootReceiver.kt
           app/src/main/res/ (layouts, menu, strings, themes, icons)
           app/src/main/jniLibs/<abi>/liblanmesh_core.so (prebuilt, tracked)
desktop/src/  main.rs (stub)  bin/voice_probe.rs (on-device test sender)
ci/  lanmesh_peer_test.py (protocol self-test, ANDROID_SERIAL-aware)

11. LIMITATIONS, HONEST GAPS & ROADMAP

  • Talk collisions. Two simultaneous talkers garble each other; the floor signals exist in the core but the app shows no "channel busy" yet — the coach sheet teaches turn-taking instead. First follow-up if it annoys.
  • Voice codec. PCM fallback today (~96 KB/s, inaudible cost on LAN); Opus (--features opus, needs libopus in the NDK) drops it to ~8–32 kbps.
  • Closed rooms. QR verify + SAS + pairwise key distribution exist in the core (sas_for, parse_qr, VerifiedPeer) but no UI calls them; the room is open by product decision. Rotation functions exist but are intentionally not auto-triggered (rotation would fork the deterministic open-room key).
  • Platforms. Android only. The Rust core already exposes a C ABI (lanmesh_* in ffi.rs) for a future iOS/desktop shell.
  • Release signing. app-release-unsigned.apk needs a keystore before sideload/Play distribution (and keystores must never be committed — *.jks/*.keystore are git-ignored).
  • License. MIT — see LICENSE. Copyright © 2026 OpenTuwa.com. ...[truncated 14718 chars]

About

Covert spying and privacy-first messaging and walkie talkie without internet broadband

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages