Spec: Tor-Only Encrypted Chat
Overview
This prototype builds a minimal Android application in which two users exchange end-to-end encrypted messages over Tor v3 hidden services — no accounts, no phone numbers, no server that can be subpoenaed. Each device runs a .onion address as its identity. Users meet in person and exchange .onion addresses together with Ed25519 public keys by scanning a QR code; after that, messages travel directly between hidden services with Tor hiding each party's IP address. This is the "opt-in online mode" layer described in the full architecture: the prototype deliberately excludes proximity, BLE, offline queuing, and group messaging so it can isolate and validate Tor latency/reliability, the QR-for-onion-address UX flow, Signal Protocol integration complexity, and Briar's forkability. See Prototype 1 on the Research Prototypes page for how this fits into the broader prototype roadmap, and Story 1: The Journalist and the Source for the human scenario this validates.
API Surface / Key Interfaces
The five interfaces below define the contract between the prototype's modules. Pseudocode uses Kotlin conventions; the Python desktop path uses equivalent signatures.
TorService.startHiddenService()
interface TorService {
/**
* Starts the Tor daemon, waits for bootstrap, and creates a v3 hidden service.
* The returned OnionAddress is the stable .onion hostname for this device.
* Must be called once at app start; the address is persisted across restarts.
*
* @param port Local TCP port the app's socket server listens on (e.g. 7070)
* @return OnionAddress — the 56-char v3 .onion hostname (without port)
* @throws TorBootstrapException if bootstrap does not complete within timeout
* @throws HiddenServiceException if HS descriptor publication fails
*/
suspend fun startHiddenService(port: Int = 7070): OnionAddress
}
data class OnionAddress(val hostname: String) // e.g. "abc123...xyz.onion"
Inputs: local TCP port to expose through Tor.
Outputs: stable v3 .onion hostname (56 characters, base32-encoded public key).
Failure modes:
TorBootstrapException— bootstrap stalls past 90 s; caller should show "connecting…" UI and retry.HiddenServiceException— HS key generation or directory publication failed; check disk write permissions to private storage.NetworkPermissionException—INTERNETpermission missing from manifest.
The private key for the hidden service is stored in Context.filesDir/hs/private_key_ed25519 (mode 0600). If the file exists on launch, the same address is restored; if absent, a new key pair is generated.
ContactStore.addContact()
interface ContactStore {
/**
* Persists a new contact derived from a completed QR exchange.
* onionAddr + publicKey together constitute the contact's identity.
*
* @param onionAddr The contact's .onion hostname
* @param publicKey The contact's Ed25519 identity public key (32 bytes)
* @param label Human-readable nickname chosen by the local user (nullable)
* @return Contact — the stored row with a locally-assigned UUID
* @throws DuplicateContactException if onionAddr already stored
*/
fun addContact(
onionAddr: OnionAddress,
publicKey: ByteArray, // 32 bytes, Ed25519
label: String? = null
): Contact
fun getContact(contactId: UUID): Contact?
fun listContacts(): List<Contact>
fun deleteContact(contactId: UUID)
}
data class Contact(
val id: UUID,
val onionAddr: OnionAddress,
val identityPublicKey: ByteArray, // Ed25519, 32 bytes
val signalSessionState: ByteArray?, // serialized Signal session, may be null pre-session
val label: String?,
val addedAt: Instant
)
Inputs: .onion hostname, 32-byte Ed25519 public key, optional label.
Outputs: Contact record with auto-generated UUID.
Failure modes:
DuplicateContactException—.onionalready in store; surface to user as "already added."StorageException— Room insert failed (disk full, corrupted DB).
Storage: Room (SQLite) with @Database schema; all columns encrypted at rest using SQLCipher (see §3).
MessageSender.send()
interface MessageSender {
/**
* Encrypts plaintext with the Signal Double Ratchet for the given contact,
* then delivers the ciphertext over a Tor SOCKS5 connection to the contact's
* .onion address.
*
* @param contactId UUID of the recipient contact
* @param plaintext UTF-8 message text (max 64 KiB before encryption)
* @return Result<MessageId> — success with server-assigned ID, or failure
*/
suspend fun send(contactId: UUID, plaintext: String): Result<MessageId>
}
sealed class Result<out T> {
data class Success<T>(val value: T) : Result<T>()
data class Failure(val error: SendError) : Result<Nothing>()
}
enum class SendError {
CONTACT_NOT_FOUND,
TOR_NOT_READY, // Tor bootstrap incomplete
ONION_UNREACHABLE, // TCP connect to .onion timed out (contact offline)
ENCRYPTION_FAILED, // Signal session not yet established
NETWORK_IO, // Generic socket error after connect
}
@JvmInline value class MessageId(val value: String) // UUID string
Inputs: contact UUID, plaintext string.
Outputs: Result.Success(MessageId) on delivery, Result.Failure otherwise.
Failure modes: see SendError — most common is ONION_UNREACHABLE when the recipient is offline. The prototype does not queue for later delivery; this is a known scope boundary.
Transport: open a SOCKS5 connection through Tor's control port to <onionAddr>:7070, write a length-prefixed protobuf frame, close. No persistent connection.
MessageReceiver.onMessage()
interface MessageReceiver {
/**
* Registers a callback invoked on the main thread whenever an inbound message
* is decrypted and persisted. The server socket runs in a foreground service.
*
* @param callback Lambda invoked with the decrypted Message
*/
fun onMessage(callback: (Message) -> Unit)
/** Stop listening and close the server socket. */
fun stop()
}
data class Message(
val id: MessageId,
val senderId: UUID, // matches Contact.id
val body: String, // decrypted plaintext
val receivedAt: Instant,
val isRead: Boolean = false
)
Inputs: callback lambda (Message) → Unit.
Outputs: none (side-effecting registration).
Failure modes: if the foreground service is killed (Doze), inbound messages are lost — this is documented in §4. The server socket binds to 127.0.0.1:7070; Tor maps external .onion:7070 to localhost:7070 via the hidden service configuration.
KeyExchange.generateQRPayload() and KeyExchange.scanQR()
interface KeyExchange {
/**
* Encodes this device's .onion address + Ed25519 identity public key
* into a compact binary payload suitable for display as a QR code.
*
* Payload format (CBOR, ~120 bytes before base45 encoding):
* { "o": <onion_hostname_string>,
* "k": <ed25519_pubkey_bytes_32>,
* "v": 1 }
*
* @return QRBytes — base45-encoded CBOR ready for ZXing BarcodeEncoder
*/
fun generateQRPayload(): QRBytes
/**
* Decodes a scanned QR payload and constructs a Contact ready to be
* passed to ContactStore.addContact().
*
* @param bytes Raw byte array from ZXing BarcodeFormat.QR_CODE scan result
* @return Contact (not yet persisted — caller must call addContact())
* @throws InvalidQRPayloadException if CBOR parse fails or key length wrong
* @throws UnsupportedVersionException if payload version field > 1
*/
fun scanQR(bytes: QRBytes): Contact
}
@JvmInline value class QRBytes(val value: ByteArray)
Inputs for generateQRPayload: none (reads own .onion address and identity key from TorService / KeyStore).
Outputs: QRBytes — base45-encoded CBOR, fits in a version 5 QR code at medium error correction.
Inputs for scanQR: raw bytes decoded by ZXing.
Outputs: un-persisted Contact struct.
Failure modes:
InvalidQRPayloadException— malformed CBOR, wrong key length, or not a Connect payload; show "Unrecognised QR code" to user.UnsupportedVersionException— payloadvfield is higher than this build supports; prompt user to update.
QR payload is intentionally small (~120 bytes CBOR / ~150 chars base45) so the QR code renders sharply on any screen and scans fast.
Dependencies & Libraries (with versions)
Android Path
| Library | Version | Purpose | License |
|---|---|---|---|
org.torproject:tor-android (Guardian Project) |
0.4.8.15 |
Tor daemon + SOCKS5 proxy + control socket | BSD-3-Clause |
net.freehaven.tor.control:jtorctl |
0.2 |
Java Tor control protocol — start HS, read bootstrap status | BSD-2-Clause |
org.signal:libsignal-android |
0.67.0 |
Signal Protocol Double Ratchet (X3DH + DR) for 1-to-1 E2EE | AGPLv3 |
com.google.zxing:core |
3.5.3 |
QR code encode/decode (BarcodeEncoder + BarcodeDecoder) | Apache-2.0 |
com.journeyapps:zxing-android-embedded |
4.3.0 |
Android ZXing integration, no additional permissions needed | Apache-2.0 |
androidx.room:room-runtime |
2.6.1 |
Local SQLite message/contact store with type-safe DAO | Apache-2.0 |
androidx.room:room-ktx |
2.6.1 |
Kotlin coroutine extensions for Room | Apache-2.0 |
net.zetetic:android-database-sqlcipher |
4.5.6 |
SQLCipher — AES-256 encryption for the Room database at rest | BSD-style |
io.github.ai.xframe:cbor (CBOR-Java) |
0.9 |
CBOR encode/decode for QR payload | Apache-2.0 |
com.google.protobuf:protobuf-javalite |
4.26.1 |
Wire format for message frames sent over TCP sockets | BSD-3-Clause |
org.jetbrains.kotlinx:kotlinx-coroutines-android |
1.8.1 |
Structured concurrency for Tor startup + send/receive | Apache-2.0 |
Alternative: Veilid path
| Library | Version | Purpose | License |
|---|---|---|---|
veilid-core (Rust crate) |
0.3.5 |
Anonymous encrypted routing, replaces Tor + Signal | MPL-2.0 |
veilid-flutter |
0.3.5 |
Flutter FFI bindings for veilid-core | MPL-2.0 |
Use Veilid path instead of Tor + Signal if you want internet-native routing without running a Tor daemon. Trade-off: Veilid is less battle-tested than Tor; the Flutter path adds Dart toolchain complexity.
Python Desktop Path
| Library | Version | Purpose | License |
|---|---|---|---|
stem |
1.8.2 |
Tor controller — launch daemon, create HS, monitor bootstrap | LGPLv3 |
cryptography |
42.0.8 |
Ed25519 key generation/signing, X25519 DH, AES-GCM for message encryption | Apache-2.0 / BSD |
qrcode[pil] |
7.4.2 |
QR code generation for terminal or GUI display | BSD |
pyzbar |
0.1.9 |
QR decode from camera frame or image file | MIT |
cbor2 |
5.6.4 |
CBOR encode/decode for QR payload | MIT |
protobuf |
5.27.3 |
Wire format for socket messages | BSD-3-Clause |
sqlalchemy |
2.0.30 |
Local SQLite message/contact store | MIT |
Version pinning rationale
tor-androidis pinned to an exact version because the Guardian Project's Maven releases lag behind upstream Tor and breaking API changes occur between minor versions.libsignal-androidis pinned exactly because Signal's Java library has no stable API guarantee between patch releases; Signal explicitly documents that callers should pin.- All other dependencies use exact pins in
build.gradleto ensure reproducible CI builds. Gradle version catalog (libs.versions.toml) is used to centralise pins. - For the Python path, all packages are pinned exactly in
requirements.txt; arequirements.lockis generated withpip-compileso transitive deps are also pinned.
Platform Constraints
Android
Minimum SDK: API 26 (Android 8.0). Minimum required for:
NotificationChannel(required for foreground service notification)JobSchedulerconstraints needed as Doze mitigation fallback- SQLCipher 4.x minimum supported API
Target SDK: API 35 (Android 15).
Foreground service: The server socket (inbound message listener) must run in a Service with startForeground() and a persistent notification. Without this, Android will kill the process within seconds when the app is backgrounded. Declare foregroundServiceType="dataSync" in the manifest (API 34+ requires an explicit type).
Battery optimisation / Doze: Android's Doze mode suspends network access for background apps. Workaround: request the REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission at first launch and prompt the user to whitelist the app. Even with the whitelist, Doze does suppress network during maintenance windows; inform users that messages may arrive with delay of minutes, not seconds, when the screen is off.
Tor startup latency: First bootstrap typically takes 30–60 seconds on a fresh install — the Tor directory must be fetched and consensus verified. Subsequent starts (daemon already has a cached directory) complete in 5–15 seconds. The UI must show a clear "Connecting to Tor…" progress state and not present the messaging UI until bootstrap reaches 100%.
Onion address persistence: The Ed25519 private key for the hidden service is stored in Context.filesDir/hs/private_key_ed25519. Android's filesDir survives app updates and device reboots. If the user clears app data, the address is lost — this is expected behaviour, equivalent to losing a private key. No cloud backup of the HS key should occur.
Network permissions (manifest):
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Camera for QR scan -->
<uses-permission android:name="android.permission.CAMERA" />
ProGuard / R8: libsignal-android ships its own AAR with native .so libraries; add the Signal-provided ProGuard rules. tor-android requires keeping its JniLoader class.
iOS
iOS does not support Tor hidden services in the background. Network.framework does not expose raw SOCKS5 proxying to background-capable network extensions in a way that would allow a hidden service to accept inbound connections while backgrounded. Onion Browser demonstrates foreground-only Tor on iOS, but a persistent .onion server is not feasible. iOS is out of scope for this prototype. The Veilid/Flutter path is the most realistic route to iOS support in a future iteration.
Desktop (Python path)
No meaningful platform constraints. Tor runs as a subprocess managed by stem. Works on macOS, Linux, and Windows (with Tor binary in PATH or bundled). The Python path is the recommended starting point for rapid iteration before committing to Android development.
Build & Test Instructions
Android path
# 1. Prerequisites
# - Android Studio Ladybug (2024.2.1) or newer
# - JDK 17+ (bundled with Android Studio)
# - Android SDK with API 26 and API 35 installed
# - Physical device or emulator with Google Play APIs (emulator needs internet for Tor)
# 2. Clone and open
git clone https://github.com/<your-org>/prototype-tor-chat.git
cd prototype-tor-chat/android
# 3. Copy Tor binaries
# tor-android AAR ships with pre-built Tor for arm64-v8a, armeabi-v7a, x86, x86_64.
# The AAR is fetched automatically via Gradle; no manual step needed.
# 4. Build debug APK
./gradlew assembleDebug
# Output: app/build/outputs/apk/debug/app-debug.apk
# 5. Run unit tests (crypto primitives, QR payload encode/decode, DB migrations)
./gradlew test
# Results: app/build/reports/tests/testDebugUnitTest/index.html
# 6. Run instrumented tests (requires connected device or running emulator)
./gradlew connectedAndroidTest
# Results: app/build/reports/androidTests/connected/index.html
# 7. Sideload APK to physical device
adb install -r app/build/outputs/apk/debug/app-debug.apk
# 8. Integration test: two-device onion message exchange
# Device A: launch app, wait for Tor bootstrap (watch logcat: "Bootstrapped 100%")
# Device B: same
# Device A: tap "Add Contact" → "Show QR" → display QR on screen
# Device B: tap "Add Contact" → "Scan QR" → scan Device A's QR
# Device B: now repeat in reverse so Device A also has Device B in contacts
# Device A: send "hello from A" to Device B contact
# Device B: message appears within ~5 seconds (P50 target)
# Check logcat on both devices for Tor circuit establishment logs
adb logcat -s TorService:D MessageSender:D MessageReceiver:D
Python desktop path
# 1. Prerequisites: Python 3.11+, Tor binary installed
# macOS: brew install tor
# Debian: apt install tor
# Windows: install Tor Expert Bundle from torproject.org
# 2. Clone and install deps
git clone https://github.com/<your-org>/prototype-tor-chat.git
cd prototype-tor-chat/python
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# 3. Generate identity and start hidden service (first run creates keys)
python torchat.py --init
# Output: Your .onion address: <56-char-hostname>.onion
# Keys stored in ~/.torchat/identity/
# 4. Start chat daemon (foreground, prints received messages to stdout)
python torchat.py --serve &
# 5. Add a contact (paste the other side's .onion address + pubkey)
python torchat.py --add-contact <onion_address> <hex_pubkey>
# 6. Send a message
python torchat.py --send <contact_id> "hello over Tor"
# 7. Run unit tests
pytest tests/unit/ -v
# 8. Run integration test (requires two terminal sessions or two machines)
# Terminal 1: python torchat.py --serve --data-dir /tmp/alice
# Terminal 2: python torchat.py --serve --data-dir /tmp/bob
# Exchange QR payloads via: python torchat.py --show-qr --data-dir /tmp/alice
# Then scan: python torchat.py --scan-qr <payload_string> --data-dir /tmp/bob
# Send and verify delivery in both directions
pytest tests/integration/ -v --timeout=120
Test matrix
| Test type | What is tested | Tool | Pass criteria |
|---|---|---|---|
| Unit — crypto primitives | Ed25519 key generation, X25519 DH, Signal session init, encrypt/decrypt round-trip | JUnit 5 / pytest | All assertions pass; zero exceptions |
| Unit — QR payload | generateQRPayload → scanQR round-trip; invalid payload rejection; version mismatch |
JUnit 5 / pytest | Encode/decode identity; InvalidQRPayloadException thrown on bad input |
| Unit — DB migrations | Room schema version 1→2 migration (if applicable); contact/message CRUD | JUnit 5 + Robolectric | No data loss; correct row counts |
| Integration — Tor connectivity | TorService.startHiddenService() completes bootstrap; HS descriptor published; self-test TCP connect to own .onion |
Instrumented (Android) / pytest | Bootstrap reaches 100% within 90 s; TCP connect succeeds |
| Integration — Signal session | X3DH key agreement between two in-process SignalProtocolStore instances; first message decrypts correctly |
JUnit 5 / pytest | Plaintext matches after round-trip |
| End-to-end — two devices | Full flow: Tor bootstrap → QR exchange → send() → onMessage() callback fires |
Two physical devices or two emulators | Message received; body matches; latency logged; no exceptions |
| Regression — no plaintext on disk | After send+receive cycle, scan Room DB file for plaintext message content | Shell script + strings |
Zero matches for test message string in DB binary |
Success Criteria
The prototype is "done" when all of the following are true:
- Tor hidden service starts within 90 seconds on a cold start (no cached directory). Measured from
Application.onCreate()to bootstrap 100% log line. - Tor hidden service starts within 20 seconds on a warm start (cached directory present). Measured same way.
- QR code exchange completes in under 10 seconds — from "Show QR" tap on Device A to contact appearing in Device B's contact list, measured with a stopwatch across 5 attempts.
- Message delivery P50 latency ≤ 5 seconds and P95 ≤ 15 seconds over Tor, measured across 50 send attempts between two physical devices on separate networks (not the same WiFi). Latency =
send()call returnsSuccesson sender toonMessage()callback fires on receiver. - Messages survive app restart — send a message, force-stop the app, relaunch, confirm the message appears in the conversation history. Tested 10 times with zero data-loss events.
- No plaintext ever written to disk — after a full send+receive session, open the Room database file with a hex editor or
strings; none of the test message bodies appear in plaintext. Confirm SQLCipher is active by verifying the database header is not the SQLite plaintext magic bytes53 51 4C 69 74 65 20 66 6F 72 6D 61 74 20 33. - Contact identity is bound to onion address — attempting to add the same
.onionaddress twice raisesDuplicateContactExceptionand does not create a second record. - Invalid QR payloads are rejected gracefully — scanning a standard URL QR code shows "Unrecognised QR code" toast, not a crash.
- App functions after device reboot — reboot the device, relaunch the app, confirm the same
.onionaddress is displayed (HS key persistence) and stored contacts are still present. - Latency findings are documented — a Markdown file
findings/latency.mdis committed to the repository with the actual P50/P95 measurements, network conditions, and device model used.
Risks & Unknowns
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Tor startup latency (30–60 s cold) makes first-run UX feel broken | High | High | Show animated "Connecting to Tor…" screen with explanatory copy; measure actual times and adjust expectations. Consider caching Tor directory aggressively. |
| Background service killed by Android Doze before message received | High | High | Request battery optimisation whitelist at first launch; foreground service with persistent notification; inform users that offline receipt is not guaranteed in prototype scope. |
libsignal-android GPLv3 / AGPLv3 license creates compliance burden |
Medium | Medium | Use only for prototype (internal build); do not distribute on Play Store without legal review. Alternatively, swap for NaCl sealed box (libsodium / Tink) — weaker forward secrecy but zero license friction. |
| Signal Protocol Java library complexity / large binary size | Medium | Medium | libsignal-android AAR is ~8 MB. For prototype purposes this is acceptable. If size is blocking, use libsodium box encryption instead. |
| Briar fork complexity + GPLv3 compliance | Medium | Medium | Strip aggressively: remove BLE, WiFi, sync protocols, mailbox. Keep only tor-android integration and Signal session code. Allocate 2 extra days if forking Briar vs building from scratch. |
| Veilid maturity — production readiness is unknown | Medium | Medium | Treat Veilid as a parallel path for exploration only; do not block prototype completion on Veilid. If Veilid path stalls, default to Tor + Signal. |
| Onion address lost after app data clear / device factory reset | Medium | Low | Document clearly. For prototype, this is acceptable. For production, explore key backup via Shamir SSS (see Key Revocation page). |
| Tor circuit build time spikes to >2 min under network adversity | Low | High | Add circuit timeout with user-visible error and retry button. Log circuit build times for each test run. |
| ZXing camera permission denial on Android 13+ | Medium | Low | Handle ActivityResultContracts.RequestPermission for CAMERA; fall back to manual .onion + pubkey text entry if user denies. |
| Two-device E2E test logistics (separate IPs needed) | Medium | Low | Use one device on mobile data, one on WiFi to ensure separate Tor exit paths. Document test setup in findings/. |
2-Week Day-by-Day Build Plan
| Day | Goal | Deliverable | Dependencies |
|---|---|---|---|
| 1 | Repository setup, Gradle skeleton, tor-android AAR integrated |
Android project builds; tor-android dependency resolves; TorService stub compiles |
Android Studio, JDK 17 |
| 2 | TorService.startHiddenService() working — Tor bootstraps, HS key generated, .onion address logged |
App launches, Tor reaches 100% bootstrap, .onion address printed to logcat |
Day 1 |
| 3 | Ed25519 identity key pair generated and persisted; QR payload CBOR schema defined | KeyStore class with generate/load; CBOR struct written and unit-tested |
Day 2 |
| 4 | KeyExchange.generateQRPayload() and KeyExchange.scanQR() implemented; QR displayed and scanned |
QR encode → decode round-trip unit test passes; ZXing camera scan works on device | Day 3 |
| 5 | ContactStore with Room + SQLCipher; addContact() and listContacts() implemented |
Room DB schema v1; migration test passes; contacts persist across app restart | Day 4 |
| 6 | libsignal-android integrated; X3DH initial key agreement between two in-process stores |
Signal session established in unit test; first message encrypted and decrypted correctly | Day 5 |
| 7 | MessageSender.send() — Signal encrypt + TCP socket send over Tor SOCKS5 |
send() delivers a message to a localhost echo server through the Tor proxy |
Day 2, 6 |
| 8 | MessageReceiver server socket + foreground service; onMessage() callback fires |
Full round-trip: send → receive on a single device via loopback .onion |
Day 7 |
| 9 | Message persistence — received messages stored in Room; conversation list query | Messages survive app restart; conversation screen shows stored history | Days 5, 8 |
| 10 | HS key and Signal session state persist across device reboot | App restores same .onion address and Signal session after reboot; no re-exchange needed |
Day 9 |
| 11 | Minimal UI — conversation list, chat screen, "Add Contact" QR flow | Functional UI connecting all implemented components; no polish required | Days 4, 9 |
| 12 | Tor bootstrap progress screen; error states (Tor timeout, contact offline, QR invalid) | All SendError cases surface informative UI; TorBootstrapException shows retry |
Day 11 |
| 13 | Two-device end-to-end test; latency measurement (50 messages, P50 / P95) | Test session completed; raw latency data recorded; findings/latency.md drafted |
Two physical devices, Day 10 |
| 14 | No-plaintext-on-disk verification; bug fixes from Day 13; findings/latency.md finalised and committed |
All success criteria checked; findings document committed; prototype tagged v0.1.0 |
Day 13 |