A self-hosted, end-to-end encrypted 1:1 chat app for iOS, built on Firebase (Auth, Firestore, Storage). No central operator reads your messages or media — encryption keys are derived on-device via an offline QR handshake and never leave the device.
This project is open source (MIT) for anyone who wants to run their own instance. There is no shared/hosted "walkytalky service" — every deployer stands up their own Firebase project and owns their own data.
- Features
- Architecture at a glance
- Self-hosting guide
- Admin workflows
- Development
- Known limitations
- License
Identity & access
- Anonymous sign-in (Firebase Auth) — no email, no phone number, no personal identifiers required
- Single-device enforcement: an account can only be active on one device at a time, tracked via a Firestore transaction
- Local 6-digit passcode lock, hashed with PBKDF2-HMAC-SHA256 (100k iterations, per-device random salt) and stored only in the iOS Keychain — never Firestore, never plaintext
- Exponential lockout after failed passcode attempts — the first two wrong guesses get an immediate retry, the third (and every one after) starts the curve (5s / 30s / 2min / 10min / 30min cap), state kept in the Keychain so it survives an app kill
- The app locks immediately when minimized — no grace period
Messaging
- 1:1 conversations, admin-paired (see Admin workflows)
- End-to-end encrypted text and media: X25519 ECDH + HKDF-SHA256 key derivation, AES-256-GCM per message
- Offline pairing via QR code — the shared key is derived entirely on-device from an ECDH exchange, never transmitted or seen by the server
- Re-pairing generates a new key version and discards the old one (old messages become permanently undecryptable — this is deliberate, not a bug)
- Delete for me / delete for everyone (sender-only, unrestricted by time), with tombstoned "This message was deleted" bubbles
- Read receipts (double-checkmark, sender's own messages only)
- Admin-assignable per-conversation display names, since accounts have no profile/email to show
- Links in messages are tappable and open in a private in-app browser (WKWebViewwith a non-persistent data store) instead of Safari — cookies, cache, and history live only in memory for that one page view and are never written to disk or shared with the system's Safari history
- Occasional background check for new messages (BGTaskScheduler), posting a local notification — always the fixed text "You have a new message," never a preview, since this check never decrypts anything. Deliberately not real push: Push Notifications is one of the capabilities Apple gates behind a paid Apple Developer Program membership, which would contradict this project's free-Apple-ID self-hosting promise below. The tradeoff is real — iOS gives this no guaranteed schedule (opportunistic, can be delayed hours, and is cancelled if the user force-quits the app) — see Known limitations
Media
- Photo capture (camera) and picking (photo library or the Files app — iCloud Drive, "On My iPhone," any other file provider), plus GIFs
- EXIF/GPS metadata stripped from every image before it ever leaves the device
- Non-GIF images are downscaled to a 4K (3840px) long edge and normalized to JPEG @ 95% before encryption — aspect ratio preserved, never upscaled
- Decrypted media renders from an app-private sandbox only — never the system Photos library, until the user explicitly shares/saves it
- Fullscreen viewer: pinch-to-zoom, drag-to-pan, and a Share button (system share sheet — AirDrop, Messages, Save Image, etc.)
- Media gallery: a grid of square thumbnails for every photo/GIF in a conversation (toolbar button in the thread view), with a paged swipe viewer to move between them
- "No Face" mode: an optional per-conversation toggle (toolbar button) that runs on-device face detection (Vision) and blurs every detected face in a photo/GIF before it's encrypted and sent. Purely local to the device that enables it — not synced to Firestore, not visible to the other participant, has no effect on media they send. Fails closed: if blurring itself fails while the mode is on, the send is aborted rather than uploading an unprotected original
UI
- WhatsApp-style chat list and thread views: bubbles, tails, date separators, live last-message previews and timestamps
- Client: SwiftUI, iOS. No custom backend server — Firebase is the only backend.
- Auth: Firebase Auth, anonymous provider only.
- Data: Cloud Firestore (conversations/{id},conversations/{id}/messages/{id},conversations/{id}/displaynames/{uid},users/{uid}), Firebase Storage for encrypted media blobs.
- Crypto: CryptoKit (X25519, HKDF-SHA256, AES-256-GCM, PBKDF2-HMAC-SHA256 built on HMAC<SHA256>since CryptoKit has no PBKDF2 API of its own).
- Security rules: firebase/SecurityRules/firestore.rulesandstorage.rulesscope every read/write to real participant membership, verified server-side — never trusting client-supplied claims. See the rules files themselves for the exact logic; they're deliberately readable.
- Admin model: conversations and per-conversation display names are created out-of-app, directly in the Firebase console (or via a script against the Admin SDK) — there is no in-app "add contact" flow. This is a deliberate scope boundary, not a missing feature.
If you're building another client against the same backend, these are the exact primitives and byte layouts messages must match to interoperate:
- QR pairing payload: JSON {"conversationId": "<string>", "publicKey": "<base64, raw 32-byte X25519 public key>"}
- Key derivation: HKDF-SHA256(ECDH(myPrivateKey, peerPublicKey), salt: UTF-8(conversationId), info: UTF-8("walkytalky-conversation-key"), length: 32 bytes)
- Message encryption: AES-256-GCM, random 12-byte nonce. The nonce is stored separately (base64) in the Firestore noncefield; the uploaded/stored payload (Storage blob for media,textfield for text) isciphertext || 16-byte GCM tagconcatenated, base64-encoded for Firestore fields.
Self-hosting means standing up your own Firebase project — there's no separate server to run. Everyone who wants their own instance does this once.
- A Mac with Xcode 26.6+ (project targets iOS 26.5; lower it in the Xcode project settings if you need to support older devices, but this hasn't been tested below that)
- An Apple ID (free tier is enough to build and run on your own devices/simulator; a paid Apple Developer Program membership is only needed for TestFlight/App Store distribution)
- A Google account, for Firebase
- Firebase CLI (npm install -g firebase-tools)
- Go to the Firebase console and create a new project.
- Authentication → Sign-in method → enable Anonymous.
- Firestore Database → create a database (Native mode — this is required, not the legacy Datastore mode).
- Storage → set up a default bucket.
- Project settings → add an iOS app. Use your own bundle identifier (the repo defaults to com.withpreet.walkytalky— change this to something you own before building for a real device, since bundle IDs must be unique per Apple Developer team).
- Download the generated GoogleService-Info.plistand place it atios/walkytalky/walkytalky/GoogleService-Info.plist(this path is already gitignored — never commit it).
cd firebase
firebase loginEdit .firebaserc and replace the project ID with your own:
{
"projects": {
"default": "your-project-id"
}
}firebase deploy --only firestore:rules,storageRead firebase/SecurityRules/firestore.rules and storage.rules first — they're the actual access-control boundary for your data. Don't deploy rules you haven't read.
- Open ios/walkytalky/walkytalky.xcodeprojin Xcode.
- Select the walkytalkytarget → Signing & Capabilities → set your own Team and Bundle Identifier (must match what you registered in step 1.5).
- Build and run on a simulator or your own device.
See Admin workflows below — you'll need at least two signed-in accounts (two devices, or two simulators) and a manually-created conversation doc before there's anything to chat in.
There is no in-app way to add a contact or start a new conversation — this is intentional, not a missing feature (see chat-app-task-list.md for the original design rationale). An admin does the following directly in the Firebase console (Firestore Database tab):
- Get each user's uid. Each user can find their own via the app's toolbar menu → "My User ID" (or the empty chat-list screen, which surfaces it directly).
- Create the conversation. Add a document to the conversationscollection with:{ "participants": ["<uidA>", "<uidB>"] }
- (Optional) Assign display names. Since accounts have no email/profile, the chat list otherwise falls back to showing a truncated uid. Add a doc per participant at conversations/{conversationId}/displaynames/{uid}:{ "displayId": "AB" }
- Pair the devices. Both users open the new conversation and use the in-app QR pairing flow (one shows their QR code, the other scans it) — this is a real-time, in-person or video-call step; the encryption key is derived entirely on-device and never touches the server.
cd ios/walkytalky
# Build
xcodebuild -project walkytalky.xcodeproj -scheme walkytalky \
-destination 'generic/platform=iOS Simulator' -configuration Debug build
# Run the unit test suite
xcodebuild -project walkytalky.xcodeproj -scheme walkytalky \
-destination 'id=<simulator-udid>' -only-testing:walkytalkyTests test
# Run the UI tests (real tap-through tests against a live Firebase backend —
# these need a booted, freshly-erased simulator to be reliable)
xcodebuild -project walkytalky.xcodeproj -scheme walkytalky \
-destination 'id=<simulator-udid>' -parallel-testing-enabled NO \
-only-testing:walkytalkyUITests testProject layout:
ios/walkytalky/walkytalky/
Auth/ sign-in, device-conflict handling, auth state
Passcode/ local passcode lock, PBKDF2, lockout
Conversations/ chat list, thread view, messages, display names
Pairing/ X25519 identity, QR generation/scanning, key derivation
Media/ capture, EXIF stripping, resize, encrypt/decrypt, upload/download
Models/ Firestore-mirrored data models, Keychain wrapper
firebase/
SecurityRules/ firestore.rules, storage.rules — read these before deploying
firebase.json, .firebaserc
chat-app-task-list.md is the full build log — every phase, every decision, every bug found and fixed, with dates. It's the most detailed record of why the code looks the way it does.
- Two participants per conversation, by design. The E2E encryption is a single AES key derived from a pairwise ECDH exchange — it mathematically cannot extend past two people without a different key-agreement scheme (e.g. Signal-style sender keys). Adding a third participant to a conversation's participantsarray will break decryption for everyone in it, not just the third person.
- No in-app contact discovery or conversation creation. Deliberate scope boundary — see Admin workflows.
- No real push notifications, on purpose. The background message check is opportunistic only — iOS decides if and when it actually runs (commonly hours apart, and cancelled entirely if you force-quit the app from the app switcher). This is a deliberate tradeoff to avoid requiring a paid Apple Developer Program membership (Push Notifications is gated behind one) just to self-host your own instance.
- No video support yet.
- No group chat.
- iOS only, for now. See below.
MIT — see LICENSE.