⚠️ Not independently audited. Messy is an in-development project built quickly. It has not had a professional security review, and it should not be relied on where a compromise could endanger someone. The design below is implemented in good faith and the honest limitations are listed, but "we tried to do it right" is not the same as "audited." If you need censorship-resistant messaging for a high-stakes threat model today, use a mature, audited tool (e.g. Signal). Treat Messy as promising, unproven software.
This document states plainly what Messy protects, how, and — just as importantly — what it does not protect.
- On first launch the app generates a single random 32-byte seed and derives, via HKDF-SHA256:
- an X25519 keypair — encryption identity
- an Ed25519 keypair — signing identity
- The seed is stored in
flutter_secure_storage, backed by the Android Keystore. It never leaves the device. There is no account, no phone number, no email. nodeId = hex(SHA-256(x25519Pub)[0..16]). Because every link handshake includes an Ed25519-signed challenge, a nodeId cannot be spoofed by a device that lacks the matching private key.
Each device maintains a pool of X25519 one-time prekeys (OTKs):
- Prekey publics are issued per peer (no two contacts ever hold the same one): a signed batch on every link-up (Ed25519 over the bundle, so a hostile network can't inject keys), plus a couple of fresh keys piggybacked inside every encrypted text and delivery receipt — long conversations replenish themselves with no server and no extra round trips.
- Sealing: the sender consumes one unused OTK and computes
K = HKDF-SHA256( DH(ephemeral, otk) ‖ DH(senderStatic, otk), salt="messy-otk-v1", info = ephPub ‖ otkPub )The ephemeral secret is discarded immediately; the envelope carriesephPuband the OTK id (both bound into the GCM associated data). - Opening: the recipient decrypts and then deletes the OTK secret — that deletion is the forward secrecy. A later compromise of the device's long-term key cannot decrypt captured ciphertext sealed to a burned OTK. The second DH authenticates the sender (only the holder of the sender's static key derives K).
- When a peer's OTK pool is dry (e.g. long offline stretch), sending falls back to the static session below — delivery is never blocked, that message simply lacks FS, and the pool refills with the next contact.
This is the OTK model (as used by store-and-forward messengers) rather than a Double Ratchet: it needs no synchronous round trips, which mesh delivery cannot guarantee. It does not provide post-compromise security — see §7.
- Key agreement: static-static ECDH —
shared = X25519(myPriv, theirPub). - Session keys: directional
sendKey = HKDF-SHA256(shared, salt="messy-v1", info = myPub ‖ theirPub)recvKey = HKDF-SHA256(shared, salt="messy-v1", info = theirPub ‖ myPub) - Per message: fresh random 12-byte nonce, AES-256-GCM. The immutable envelope header (messageId, sender/recipient keys, timestamp, payload type, chunk fields, and the FS fields) is authenticated as GCM associated data, so a relay that tampers with routing metadata breaks authentication at the recipient. TTL and hop count are excluded (they legitimately change per hop).
- Media (manifest + 32 KiB chunks, each with its own nonce, SHA-256-verified on reassembly) intentionally uses the static session: one video would burn hundreds of OTKs. Per-transfer keys are the planned upgrade.
- OTK secrets live in the local SQLite database (
secure_deleteon), not the Keystore — Android's Keystore cannot hold a rotating pool of raw X25519 keys. Unused keys expire after 14 days.
Intermediate mesh hops store and forward ciphertext only. They can observe: sender and recipient public keys, message IDs, sizes, timing, TTL/hops. They can never read content. Traffic-analysis metadata is not hidden in v1.
| Method | Trust | Rationale |
|---|---|---|
| QR scan | verified | Keys exchanged by physical presence — a trusted channel. |
| Nearby request/accept | unverified | Keys exchanged over the air; a MITM on first contact is theoretically possible. The UI shows a 6-word fingerprint phrase (derived from SHA-256 over both public keys, sorted) that both users can compare out-of-band, plus a nudge to re-verify via QR. |
The "Local" room and the public "Media" channel are encrypted with a key derived from a public constant. Every copy of Messy can derive this key, so this is obfuscation, not confidentiality: treat anything posted there as public speech. Posts expire everywhere after 24 h.
Sender authentication. Because the channel key is shared, the senderPub field alone proves nothing — anyone could set it. So every public/group post is Ed25519-signed over messageId ‖ senderPub ‖ timestamp ‖ payloadType ‖ content, and the receiver rejects any post whose signature doesn't verify. If the sender key matches a known contact whose signing key we hold, the post must be signed by exactly that key — so a stranger cannot impersonate a saved contact. (An attacker can still mint a fresh anonymous identity per post; that's inherent to a registration-free network, and is what the moderation layer in §9 addresses.) Group media chunks carry no separate signature — their integrity comes from the SHA-256 in the signed manifest, so a forged chunk fails reassembly.
No auto-download in the public Media channel. Public media is never fetched to disk or shown automatically. The manifest arrives, chunks are held encrypted-at-rest in the app database (never materialized as a gallery file), and the image/video is only assembled and displayed when the user explicitly taps "download & view" on a placeholder marked unverified sender. This stops drive-by illegal content from silently landing in a user's gallery or being auto-redistributed. Media inside 1:1 chats and invite-only groups (consensual, bounded audiences) still auto-downloads.
A serverless anonymous channel has no gatekeeper — you cannot prevent someone posting. Every defense is therefore client-side, and Messy layers several:
- Block ("block for me"). Blocking a node hides its messages, purges everything it sent that you stored, and makes your device stop relaying its posts — so a spammer loses reach through everyone who blocks them. It cannot stop them transmitting to others.
- Relay rate limiting. A per-sender token bucket caps how much any one node can push through your device, so a flooder can't fill relay budgets, drain batteries, or drown out Local.
- Web-of-trust blocklists. When you block someone, a signed block record is shared with your verified contacts on connection. If several verified contacts have each blocked the same node, you auto-mute it too. Only manually-created blocks are shared (never auto-blocks, to prevent cascades), and only verified contacts get a vote (an unverified/sybil identity cannot manufacture auto-blocks).
This is the ceiling of what a serverless design allows: abuse becomes low-reach, non-persistent, and attributable — but not impossible. Users should understand that public channels are open-broadcast spaces.
- A PIN is set up mandatorily during onboarding and the gate is on by default — Messy starts at maximum security and only the user can reduce it.
- The PIN is required to open the app at least once every 24 h; a successful unlock is valid for 24 h across restarts.
- Disabling the gate in Settings requires entering the current PIN.
- Hashing: Argon2id (memory-hard KDF: 19 MiB, 2 passes) over the PIN + a random 16-byte salt, stored in the Keystore-backed secure store. Comparison is constant-time.
- Rate limiting: after a few free attempts, wrong PINs trigger escalating lockouts (5s → 15s → 60s → 5m → 15m → 1h). While locked, even the correct PIN is refused until the window passes. Attempt/lockout state lives in secure storage, so it survives an app-data or database wipe. This is the primary defense given a numeric PIN's small keyspace.
- The database is encrypted with SQLCipher (AES-256). The encryption key is a random 256-bit value generated on first launch and held in
flutter_secure_storage(Android Keystore, hardware-backed on most devices). A copied database file is unreadable without the Keystore key — so OTK private keys, relay-store ciphertext, and routing metadata are not on disk in plaintext. PRAGMA secure_delete = ON— freed pages are overwritten, not merely unlinked.- No cloud storage, no sync, no server.
android:allowBackup="false"keeps chats out of Google/device backups. - Screenshot/recents protection: an optional Settings toggle applies Android
FLAG_SECURE, blocking screenshots and screen recording and hiding the app's content in the recent-apps switcher. (Off by default; the toggle is now actually wired to the window flag.) - Disappearing messages: per-chat timer (off / 1 h / 24 h / 7 d) carried inside the encrypted payload so both ends enforce it. Cooperative, not cryptographic: a malicious recipient can screenshot or fork the app. This is the same honesty caveat Signal/WhatsApp carry.
- 24 h auto-wipe (opt-in): wipes messages, media files, the relay store, and the seen-envelope set every 24 h; identity and contacts survive. Enforced by a periodic job plus a sweep-on-launch guarantee (nothing older than 24 h is ever displayed even if the background job was deferred by the OS).
- Relayed foreign envelopes are ciphertext and are evicted at 72 h (24 h for public posts) or under storage pressure.
- Forward secrecy is per-message and best-effort, not absolute. Texts, receipts, and group invites get FS whenever an unused one-time prekey is available; the first messages to a brand-new contact met over the mesh, messages after a long offline stretch, and all media fall back to the static session (no FS for those). There is no post-compromise security: an attacker who fully compromises a device can read messages going forward until keys age out — healing requires a Double Ratchet, which store-and-forward delivery cannot guarantee. Group messages use a static shared group key (no FS by design). The 24 h auto-wipe and disappearing messages shrink the on-device exposure window.
- Metadata visible to relays (who↔who, when, how much). Mitigating this (onion routing, cover traffic) is out of scope for v1.
- First-contact MITM for nearby adds — mitigated by the fingerprint phrase and QR re-verification, not eliminated.
- Deniability/anonymity are non-goals. Messages are signed; participation on a mesh is observable by radio proximity.
- Compromised relay behavior: a hostile relay can drop, delay, or replay envelopes. Replays are deduplicated by message ID; drops are mitigated only by epidemic redundancy.
allowBackup="false"keeps app data out of backups- SQLCipher-encrypted database; Keystore-backed identity seed and DB key; no key material in SharedPreferences or logs
- Argon2id-hashed PIN with rate-limited lockout
FLAG_SECURE(blocks screenshots/recents thumbnail) — Settings toggle, implemented via the window flag- No third-party analytics/telemetry of any kind
- Dependency pinning +
flutter pub outdatedreview each release
- Not audited (see the banner at the top). No external cryptographic or code review.
- PIN keyspace is small. A numeric PIN has ~10^6 combinations; Argon2id + lockout slow an attacker but a determined adversary with the unlocked device and time is not stopped by a PIN alone.
- DB key is not PIN-derived. The SQLCipher key lives in the Keystore, protecting against a copied database file. It does not require the PIN to unlock the data, so an attacker who can run code as the app on an unlocked, non-rooted device could still reach the key via the Keystore. PIN-derived DB keys are a future hardening.
- Metadata still visible to relays (who↔who, when, sizes) — see §2. No onion routing / cover traffic.
- No post-compromise security (no Double Ratchet) — see §7.
- The radio transports (BLE, Wi-Fi Aware, Wi-Fi Direct) are unverified on hardware and have not been security-reviewed for their own attack surface.