Offline mesh messaging

The mesh that survives
the blackout.

When the internet is gone, Wisp turns phones into a mesh — passing local news, life-safety alerts and encrypted messages device to device over Wi-Fi and QR codes. No servers, no accounts, no signal required.

Alert · priority 2SIGNED

Field clinic open — Central School, Main Rd

[medical] · zone north · ttl 48h

Content address

7c1a4f90b2e83d55a6ff0c1748d29b3e

Chain of custody

  1. 01amber-hare-46sig ok
  2. 02quiet-fox-11sig ok
  3. 03slate-owl-03sig ok

Illustrative record · shape matches src/types/message.ts

  • 0

    servers, accounts, API keys

  • 3

    transport tiers

  • 8

    ingest validation gates

  • 4

    IndexedDB stores

  • 1 KB

    Bloom fingerprint on the wire

  • 128-bit

    content address per message

The problem

Every messaging app assumes a server in the middle.

In a blackout, a disaster or a deliberate shutdown, people standing metres apart lose the ability to share what matters: safe routes, medical aid, where the water truck stopped. The moment infrastructure fails, so does everything built on top of it.

Proximity survives even when connectivity doesn't. Wisp is a client-only PWA that gossips signed, content-addressed messages between nearby devices — over a local WebRTC channel when there is Wi-Fi, through the camera as animated QR when there isn't, and via printed QR posters when there is nothing at all.

What it does

Built for the conditions where nothing else works.

  • No-account identity

    A locally generated Ed25519 keypair is your identity. Nothing is registered anywhere, because there is nowhere to register.

  • Daily aliases

    Your display name is derived from your key and the date, so it rotates every day and can't be followed across them.

  • Three-tier sync

    WebRTC on a shared network, camera-to-camera animated QR, or a printed QR poster. It degrades instead of failing.

  • Encrypted direct messages

    DMs are sealed with nacl.box before they ever leave the device, so relays carry ciphertext they cannot read.

  • Verifiable hop trail

    Every relay signs a hop stamp over the message id and the previous signature, forming a chain of custody you can inspect.

  • Panic wipe

    One tap clears every store and regenerates a fresh identity. Messages also self-delete on a type-based timer.

Under the hood

There is no architecture diagram with a server on it.

Everything runs on the device. Posts are signed and content-addressed, then written to IndexedDB across four object stores. When two devices meet, a transport-agnostic gossip protocol exchanges Bloom fingerprints and moves only the difference, highest priority first.

  1. Tier 1 · WebRTC

    A data channel over the shared local network. Fastest path, and the only one that moves a full store in seconds.

  2. Tier 2 · Camera QR

    No Wi-Fi at all: gzip payloads play as animated QR frames and the other camera reassembles them, capped to keep frame counts sane.

  3. Tier 3 · Printed drops

    A bundle of recent news and top alerts printed as a poster. Anyone who passes can scan it and print an updated one elsewhere.

One sync, end to end

Four phases, then eight gates every inbound message must pass before it is stored. Nothing is taken on trust.

idle · two devices in range · press run

Rejections are silent by design — a tampered id or a bad hop signature is dropped with a console warning and no negative acknowledgement to the sender.

Three decisions worth defending

Transport

WebRTC with no ICE servers, and trickle disabled.

In a real blackout there is no STUN or TURN server to reach, and both phones are on the same local network anyway, so only host candidates can ever matter — an ICE server list would be dead weight that also leaks a dependency on the internet. Disabling trickle makes the browser emit one complete SDP instead of a stream of candidates, which is precisely what lets the entire handshake fit inside a scannable QR code.

src/transport/rtc.ts:16

Sync protocol

Trade fingerprints, not inventories.

Each device hashes its message ids into an 8192-bit Bloom filter with four SHA-256-derived hashes — about a kilobyte — and sends that instead of a list. The peer checks its own ids against the filter and transfers only what is probably missing. The roughly 1% false-positive rate means an occasional message waits for the next encounter: completeness traded for a payload small enough to survive a camera link.

src/sync/bloom.ts:18

Message design

Content addressing over a canonical form.

A message id is the SHA-256 of a canonical serialisation with keys sorted, whitespace stripped, and the volatile fields — signature, hops, hidden — excluded. Two devices that have never met therefore produce byte-identical input and the same id, so duplicates collapse for free and any edit changes the address. Tamper detection isn't a separate check; it falls out of how the id is built.

src/lib/canonical.ts:37

Stack

Client
React 19TypeScript 6Vite 8ZustandTailwindframer-motion
Offline
vite-plugin-pwaWorkbox service workerDexie over IndexedDB4 object stores
Crypto
@noble/ed25519@noble/hashestweetnacled2curveX25519 + XSalsa20-Poly1305
Transport
simple-peer (WebRTC)qrcode@zxingnative BarcodeDetectorpako / CompressionStream
Server
none

Honest limitations: WebRTC needs both phones on the same local network, sync completeness is probabilistic, and local storage is currently bounded only by message expiry — the documented 500-message LRU eviction is not built yet. Throughput and maximum practical mesh size are unmeasured.