Spec: Local WiFi Relay Server
Status: Draft v0.1 — 2026-04-23
1. Overview
This document is the full technical specification for Prototype 5: Local WiFi Relay Server, whose plain-language motivation is told in Story 5: The Community Space. The prototype replaces phone-to-phone peer discovery with a small dedicated server: a Raspberry Pi boots, creates its own WiFi hotspot (no internet connection required), and runs a message relay daemon. Phones in range connect to the Pi's WiFi network and communicate through the relay. Every message is end-to-end encrypted on the sender's device before it leaves; the relay stores and forwards opaque ciphertext blobs it cannot read. The server is a dumb mailbox with labelled slots — it knows who a parcel is addressed to (a public-key hash) and when it arrived, but the contents are sealed. Optional feed-replication endpoints let phones also exchange signed social-feed envelopes through the same server, using the format defined in the Feed Format Spec.
2. API Surface / Key Interfaces
2.1 HTTP REST Endpoints
All endpoints run on the Pi at http://192.168.4.1:8080 (or https:// with a self-signed cert — see Section 4.2). Requests and responses are JSON. Authentication uses a Bearer token returned at registration.
POST /register
Register a new identity on this relay. Called once per device per relay. The server assigns a stable feed_id derived from the public key and issues a session token.
Request body
{
"pubkey_ed25519": "<base64url-encoded 32-byte Ed25519 public key>",
"pubkey_x25519": "<base64url-encoded 32-byte X25519 public key>"
}
Response 200 OK
{
"feed_id": "<base64url — SHA-256(pubkey_ed25519) truncated to 16 bytes>",
"token": "<JWT-like session token — see Section 2.3>",
"expires_at": 1745500000
}
Error codes
| Code | Meaning |
|---|---|
400 |
Malformed request (missing or invalid fields) |
409 |
feed_id already registered (same pubkey, second registration) — returns existing token |
422 |
Public key fails length / encoding validation |
Notes: The server does not validate the Ed25519 key against a CA or any external authority. feed_id is public and can be shared with contacts. The server stores both public keys to allow senders to address messages; it never holds private keys.
POST /messages/send
Send an encrypted message to another registered feed. The server stores the ciphertext and nonce without inspecting them.
Auth: Authorization: Bearer <token>
Request body
{
"recipient_feed_id": "<base64url>",
"ciphertext": "<base64url — XChaCha20-Poly1305 AEAD output>",
"nonce": "<base64url — 24-byte nonce>"
}
The ciphertext is produced by the sender's phone using the shared secret derived from an X25519 DH between the sender's private key and the recipient's pubkey_x25519. The server receives only the opaque bytes.
Response 201 Created
{
"message_id": "<UUID v4>",
"stored_at": 1745490000
}
Error codes
| Code | Meaning |
|---|---|
401 |
Missing or expired token |
404 |
recipient_feed_id not registered on this relay |
413 |
Ciphertext exceeds 64 KB limit |
429 |
Rate limit exceeded (max 60 sends per token per minute) |
GET /messages/inbox
Retrieve all pending messages addressed to the authenticated feed. The client is responsible for decryption. After pickup, messages should be deleted with DELETE /messages/{id}.
Auth: Authorization: Bearer <token>
Response 200 OK
[
{
"message_id": "<UUID v4>",
"sender_feed_id": "<base64url>",
"ciphertext": "<base64url>",
"nonce": "<base64url>",
"stored_at": 1745490000
}
]
Empty array [] when inbox is empty.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
since |
unix timestamp | 0 |
Return only messages stored after this time |
limit |
int | 100 |
Max messages per response |
Error codes
| Code | Meaning |
|---|---|
401 |
Missing or expired token |
DELETE /messages/{message_id}
Delete a message after the recipient has picked it up and successfully decrypted it. This is the expected flow — inbox is not automatically cleared.
Auth: Authorization: Bearer <token>
Response 204 No Content
Error codes
| Code | Meaning |
|---|---|
401 |
Missing or expired token |
403 |
Message does not belong to this feed |
404 |
Message not found (already deleted or never existed) |
POST /feeds/publish (optional — social feed replication)
Publish a signed feed envelope (per the Feed Format Spec) to the relay for distribution to other clients. The server stores the raw envelope JSON string without parsing it; it is the sender's responsibility to produce a valid, signed envelope.
Auth: Authorization: Bearer <token>
Request body
{
"envelope_json": "<string — the full signed envelope JSON from the feed-format spec>"
}
Response 201 Created
{
"sequence": 42,
"stored_at": 1745490000
}
Error codes
| Code | Meaning |
|---|---|
400 |
envelope_json is not valid JSON |
401 |
Missing or expired token |
413 |
Envelope exceeds 128 KB |
409 |
Duplicate sequence number for this feed |
GET /feeds/{feed_id}/since/{sequence}
Fetch all envelopes published by feed_id with sequence number greater than {sequence}. Used for delta sync — the client passes the last sequence it has locally.
Response 200 OK
[
"<envelope_json string>",
"<envelope_json string>"
]
Ordered by ascending sequence number. Empty array when the client is up to date.
Error codes
| Code | Meaning |
|---|---|
404 |
feed_id unknown on this relay |
416 |
sequence out of range |
2.2 WebSocket: ws://192.168.4.1:8080/stream
A persistent WebSocket connection for push notifications. The client authenticates by sending its token as the first message after the handshake opens.
Auth handshake (client → server)
{ "type": "auth", "token": "<Bearer token>" }
Server response on success
{ "type": "auth_ok", "feed_id": "<base64url>" }
Push event: new message
When a message arrives in the authenticated feed's inbox, the server pushes:
{
"type": "new_message",
"message_id": "<UUID v4>",
"sender_feed_id": "<base64url>",
"stored_at": 1745490000
}
The push event does not include the ciphertext — the client must call GET /messages/inbox (or GET /messages/inbox?since=<ts>) to retrieve the payload. This keeps the WebSocket channel lightweight and avoids re-implementing message delivery over two paths.
Keepalive: Server sends {"type":"ping"} every 30 seconds; client should respond {"type":"pong"}. Connections idle for more than 90 seconds without a pong are closed.
2.3 Session Token Format
The token is a compact signed payload, not a full JWT (no JOSE overhead). Structure:
base64url( feed_id || expires_at_uint32_be || nonce_8 ) || "." || base64url( HMAC-SHA256(server_secret, above) )
feed_id: 16 bytesexpires_at: 4-byte big-endian Unix timestamp (token valid for 24 hours by default)nonce_8: 8 random bytes to prevent token collisions across re-registrations- The HMAC key is a 32-byte secret generated once at server startup and stored in the SQLite config table
Clients present the full opaque string as a Bearer token. The server verifies the HMAC and checks expiry. There is no token refresh endpoint in v0.1 — clients re-register when the token expires.
2.4 Phone App SDK Interfaces
These are the public interface contracts the phone-side SDK must expose, regardless of underlying HTTP/WebSocket implementation.
// Kotlin (Android) — iOS mirror in Swift uses async/await equivalents
interface RelayClient {
/**
* Register this device's key bundle with the relay.
* Generates a session token stored locally; call once per relay.
*/
suspend fun register(myKeys: KeyBundle): SessionToken
/**
* Encrypt [plaintext] for [recipientFeedId] and send to relay.
* Returns the relay-assigned message ID on success.
* Queues locally and retries if relay is unreachable.
*/
suspend fun sendMessage(
recipientFeedId: FeedId,
plaintext: ByteArray
): MessageId
/**
* Poll inbox once. Returns list of encrypted messages.
* Caller is responsible for decryption and calling deleteMessage().
*/
suspend fun pollInbox(): List<EncryptedMessage>
/**
* Open a WebSocket and invoke [callback] for each new push event.
* Reconnects automatically on network drop.
*/
fun subscribeInbox(callback: (EncryptedMessage) -> Unit): Subscription
/**
* Delete a message from the relay after successful decryption.
*/
suspend fun deleteMessage(messageId: MessageId)
/**
* Publish a signed feed envelope for social feed replication.
*/
suspend fun publishFeedEnvelope(envelopeJson: String)
/**
* Fetch feed envelopes from [feedId] newer than [sinceSequence].
*/
suspend fun fetchFeedSince(feedId: FeedId, sinceSequence: Int): List<String>
}
data class KeyBundle(
val ed25519PublicKey: ByteArray, // 32 bytes
val ed25519PrivateKey: ByteArray, // 64 bytes (seed+public)
val x25519PublicKey: ByteArray, // 32 bytes
val x25519PrivateKey: ByteArray // 32 bytes
)
data class EncryptedMessage(
val messageId: String,
val senderFeedId: String,
val ciphertext: ByteArray,
val nonce: ByteArray,
val storedAt: Long
)
3. Dependencies & Libraries
3.1 Server (Raspberry Pi, Raspbian Bookworm, ARM64)
Recommended language: Go 1.22+
Rationale: Go compiles to a single static binary with no runtime dependency, cross-compiles trivially from a dev machine to ARM64 (GOARCH=arm64 GOOS=linux), and net/http plus gorilla/websocket are enough for the entire server. The pure-Go SQLite driver avoids CGo, which simplifies cross-compilation further. Python is faster to prototype but slower at runtime and requires a venv on the Pi; Rust is excellent but longer to compile (especially cross-compile) and higher initial complexity for limited benefit at this scale.
| Package | Version | Purpose |
|---|---|---|
go |
1.22+ |
Language runtime + stdlib (net/http, crypto/hmac, encoding/base64) |
gorilla/websocket |
^1.5 |
WebSocket server (github.com/gorilla/websocket) |
modernc.org/sqlite |
^1.29 |
Pure-Go SQLite driver — no CGo, single binary cross-compile (modernc.org/sqlite) |
google/uuid |
^1.6 |
UUID v4 generation (github.com/google/uuid) |
golang.org/x/crypto |
latest | chacha20poly1305, ed25519, curve25519 |
hostapd |
2.10 (apt) |
WiFi access point daemon (system package) |
dnsmasq |
2.89 (apt) |
DHCP server for hotspot clients (system package) |
systemd |
OS default | Service management (auto-start, restart on failure) |
Python alternative (if faster iteration is needed)
| Package | Version | Purpose |
|---|---|---|
python |
3.12+ |
Runtime |
fastapi |
^0.111 |
HTTP framework |
uvicorn |
^0.30 |
ASGI server |
aiosqlite |
^0.20 |
Async SQLite |
python-jose[cryptography] |
^3.3 |
Token generation/validation |
PyNaCl |
^1.5 |
libsodium bindings (key validation) |
3.2 Phone App
Android
| Library | Version | Purpose |
|---|---|---|
ktor-client-android |
^2.3 |
HTTP client (KMP-ready, coroutine-native; preferred over OkHttp for KMP compatibility) |
ktor-client-websockets |
^2.3 |
WebSocket support |
tink-android |
^1.13 |
E2E encryption — XChaCha20-Poly1305 AEAD, X25519 key agreement (com.google.crypto.tink:tink-android) |
androidx.room |
^2.6 |
Local message cache + outbound queue (androidx.room:room-ktx) |
kotlinx-coroutines-android |
^1.8 |
Async / suspend |
androidx.security:security-crypto |
^1.1.0-alpha06 |
Android Keystore-backed key storage |
iOS
| Library | Version | Purpose |
|---|---|---|
URLSession |
iOS 13+ (built-in) | HTTP client |
URLSessionWebSocketTask |
iOS 13+ (built-in) | WebSocket client |
CryptoKit |
iOS 13+ (built-in) | Curve25519, ChaCha20-Poly1305, HMAC (Apple's hardware-backed crypto) |
SwiftData / CoreData |
iOS 17+ / iOS 3+ (built-in) | Local message cache |
iOS uses only system frameworks — no external dependencies required, which eliminates supply-chain risk and simplifies App Store review.
4. Platform Constraints
4.1 Server Side (Raspberry Pi 4 / 5)
Hardware
- CPU: ARM Cortex-A72 (Pi 4) / Cortex-A76 (Pi 5), ARM64
- RAM: 4 GB (Pi 4) / 8 GB (Pi 5) — relay server idle footprint: ~20 MB (Go binary)
- WiFi: Built-in
wlan0— 802.11ac (Pi 4/5), 2.4 GHz + 5 GHz - Power: Pi 4 draws ~5 W idle, ~7 W under load over USB-C; a 20 000 mAh power bank provides 10–15 hours of operation
- Storage: 8 GB SD card is sufficient; messages expire after 72 h max so SQLite file stays small
WiFi hotspot setup
hostapd runs in AP mode on wlan0. The Pi issues DHCP leases on the 192.168.4.0/24 subnet via dnsmasq. If an ethernet uplink (eth0) is available, iptables NAT bridges it to the hotspot — but no internet uplink is required. The relay is fully air-gapped.
Phones (192.168.4.2–254)
│ WiFi 802.11ac
▼
Pi wlan0 (192.168.4.1) ← relay server listens here
│ (optional)
▼
Pi eth0 → internet uplink (if present)
SD card wear
SQLite write-ahead logging (WAL mode) reduces random writes. For high-throughput scenarios consider a USB SSD or a tmpfs mount for the SQLite WAL file. Messages older than 72 h are purged by a background goroutine running every 15 minutes, capping database growth.
TLS / HTTPS
Two options with different trade-offs (see also Section 7 — Risks):
| Option | Trade-off |
|---|---|
Plain HTTP on 192.168.4.1:8080 |
Zero setup friction. Acceptable on a local air-gapped WiFi network the operator controls. No certificate trust issues on phones. Vulnerable to passive eavesdropping by other phones on the same hotspot. |
| HTTPS with self-signed cert | Encrypts traffic between phone and relay. Both Android and iOS reject self-signed certs by default — requires either a custom trust store in the app, certificate pinning, or user-visible trust prompt. Adds implementation complexity. |
v0.1 recommendation: Use plain HTTP for the prototype, with a clear note that production deployments should use HTTPS (possibly via a small local CA or mDNS + Let's Encrypt over a temporary internet uplink).
4.2 Phone Side
Android — "No internet" WiFi problem
When a phone connects to a WiFi network without an internet uplink (which is always the case for the Pi's hotspot), Android automatically routes traffic over mobile data and ignores the WiFi network. This breaks the relay entirely.
Two mitigations:
- User instruction: Disable "Switch to mobile data when WiFi has no internet" in Android WiFi settings. Simple but requires manual user action.
NetworkRequestAPI (programmatic): Request the specific WiFi network in the app:
val networkRequest = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.build()
connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
// Bind all HTTP traffic to this network
connectivityManager.bindProcessToNetwork(network)
// OR use OkHttpClient / Ktor with a custom socket factory bound to `network`
}
})
The NetworkRequest approach is the right long-term solution — it works without user settings changes and is available from Android 5.0+.
iOS — Joining the hotspot
iOS shows a system prompt asking the user to confirm joining a WiFi network without internet. The NEHotspotConfiguration API can automate this:
let config = NEHotspotConfiguration(ssid: "connect-relay", passphrase: "passphrase", isWEP: false)
config.joinOnce = false // persist across sessions
NEHotspotConfigurationManager.shared.apply(config) { error in
if let error { /* handle */ }
}
Requires the com.apple.developer.networking.HotspotConfiguration entitlement. iOS 11+.
No BLE dependency
WiFi has none of BLE's background restrictions on either platform. The phone app requires no special permissions beyond ACCESS_WIFI_STATE and CHANGE_WIFI_STATE on Android; no special capability on iOS beyond the hotspot entitlement.
Offline queue
The phone app must queue outbound messages in Room / CoreData when the relay is unreachable and retry on reconnect. Retry strategy: exponential backoff starting at 2 s, cap at 60 s, unlimited retries until the message is delivered or the user is outside WiFi range.
5. Build & Test Instructions
5.1 Server Setup (Raspberry Pi)
# 1. Install OS packages (Raspbian Bookworm)
sudo apt update && sudo apt install -y hostapd dnsmasq git
# 2. Configure hostapd
sudo tee /etc/hostapd/hostapd.conf > /dev/null <<'EOF'
interface=wlan0
driver=nl80211
ssid=connect-relay
hw_mode=g
channel=6
wmm_enabled=0
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0
wpa=2
wpa_passphrase=changeme1234
wpa_key_mgmt=WPA-PSK
wpa_pairwise=TKIP
rsn_pairwise=CCMP
EOF
echo 'DAEMON_CONF="/etc/hostapd/hostapd.conf"' | sudo tee -a /etc/default/hostapd
# 3. Configure dnsmasq
sudo tee /etc/dnsmasq.conf > /dev/null <<'EOF'
interface=wlan0
dhcp-range=192.168.4.2,192.168.4.254,255.255.255.0,24h
EOF
# 4. Assign static IP to wlan0
sudo tee -a /etc/dhcpcd.conf > /dev/null <<'EOF'
interface wlan0
static ip_address=192.168.4.1/24
nohook wpa_supplicant
EOF
# 5. Enable IP forwarding (optional — only needed for internet uplink bridging)
echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
# 6. Enable and start services
sudo systemctl unmask hostapd
sudo systemctl enable hostapd dnsmasq
sudo systemctl start hostapd dnsmasq
Build and deploy the relay server (Go)
# Cross-compile from macOS/Linux dev machine:
GOARCH=arm64 GOOS=linux go build -o relay-arm64 ./cmd/relay
# Copy to Pi:
scp relay-arm64 pi@raspberrypi.local:/usr/local/bin/relay
# On the Pi — install systemd unit:
sudo tee /etc/systemd/system/relay.service > /dev/null <<'EOF'
[Unit]
Description=Connect Relay Server
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/relay --db /var/lib/relay/messages.db --port 8080
Restart=always
RestartSec=5
User=relay
WorkingDirectory=/var/lib/relay
[Install]
WantedBy=multi-user.target
EOF
sudo useradd -r -s /bin/false relay
sudo mkdir -p /var/lib/relay && sudo chown relay:relay /var/lib/relay
sudo systemctl daemon-reload
sudo systemctl enable relay && sudo systemctl start relay
# Verify:
sudo systemctl status relay
Smoke test the server
# Register a fake identity:
curl -s -X POST http://192.168.4.1:8080/register \
-H "Content-Type: application/json" \
-d '{"pubkey_ed25519":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","pubkey_x25519":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="}' \
| jq .
# Expected: { "feed_id": "...", "token": "...", "expires_at": ... }
# Check inbox (with token from previous step):
curl -s http://192.168.4.1:8080/messages/inbox \
-H "Authorization: Bearer <token>" | jq .
5.2 Phone App Build
Android
# Clone and build:
./gradlew assembleDebug
# Install on connected device:
adb install app/build/outputs/apk/debug/app-debug.apk
# Run unit tests:
./gradlew testDebugUnitTest
# Run instrumented tests (device connected):
./gradlew connectedDebugAndroidTest
iOS
# Open in Xcode:
open ConnectRelay.xcodeproj
# Build + run on simulator:
xcodebuild -scheme ConnectRelay -sdk iphonesimulator -destination \
'platform=iOS Simulator,name=iPhone 15' build
# Run unit tests:
xcodebuild test -scheme ConnectRelayTests -sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 15'
5.3 Test Matrix
| Layer | Test | Pass Criterion |
|---|---|---|
| Unit | E2E encrypt/decrypt roundtrip | Phone A encrypts; relay stores ciphertext; Phone B decrypts original plaintext |
| Unit | Message TTL expiry | Messages inserted 73 h ago absent from GET /messages/inbox; background cleanup deletes from DB |
| Unit | Token validation | Tampered HMAC → 401; expired timestamp → 401 |
| Unit | Rate limiting | 61st send in 60 s → 429 |
| Integration | Two-phone message delivery | Phone A registers, Phone B registers, A sends to B, B polls inbox, message delivered, B decrypts OK |
| Integration | Relay survives reboot | Insert messages, reboot Pi, GET /messages/inbox returns same messages |
| Integration | WebSocket push | A sends message to B; B's open WebSocket receives new_message event within 2 s |
| Integration | Offline queue | Relay unreachable; Phone A queues message locally; relay restarts; message delivered within retry window |
| Integration | Feed replication | A publishes 5 envelopes; B calls GET /feeds/{a_feed_id}/since/0; receives all 5 |
| Security | Server cannot decrypt | Tamper single byte of ciphertext; client decryption → AEAD authentication failure (Poly1305 tag mismatch) |
| Security | Cross-feed access | Phone A calls DELETE /messages/{id_belonging_to_B} → 403 |
| Load | 20 concurrent phones | 20 devices simultaneously sending 100 messages each; no 5xx errors; p99 latency < 2 s |
| Measurement | Boot-to-ready time | From Pi power-on to first successful POST /register < 90 s |
| Measurement | WiFi range | Successful message exchange at 20 m without external antenna; 50 m with USB WiFi dongle |
6. Success Criteria
- Pi powers on,
hostapdcreates the WiFi hotspot, and the relay service starts and accepts connections — all within 90 seconds of power-on. - A phone discovers the hotspot in the WiFi list, connects, and completes
POST /registerin under 10 seconds from the moment the user taps "Connect." - A message sent by Phone A is available in Phone B's
GET /messages/inboxwithin 2 seconds, both phones connected simultaneously. - A message sent to an offline recipient persists in the relay and is delivered when the recipient reconnects, up to 72 hours after sending.
- The relay cannot decrypt any stored message — verified by the AEAD tamper test: modified ciphertext produces a client-side decryption error, not corrupted plaintext.
- The relay handles 20 simultaneous phone connections with 100 messages each with zero server errors and p99 latency under 2 seconds.
- The phone app queues messages locally when the relay is unreachable and delivers them automatically on reconnect, with no user action required.
- A phone connected to the Pi's hotspot can exchange messages with another phone at a minimum 20 metres from the Pi without an external antenna, and 50 metres with a USB WiFi dongle with an external antenna.
7. Risks & Unknowns
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Android routes traffic over mobile data instead of the local WiFi (no-internet detection) | High | High | Implement NetworkRequest + bindProcessToNetwork on Android; document user setting as fallback |
| Self-signed TLS certificate rejected by default on Android and iOS | High | Medium | Use plain HTTP for v0.1 prototype; document upgrade path to HTTPS with trust store or cert pinning for later iterations |
| Pi SD card failure from frequent SQLite writes (flash wear) | Medium | High | Enable WAL mode; run cleanup job every 15 min to cap DB size; consider tmpfs for WAL file; document backup to USB SSD for production use |
| Session token replay — token stolen on local network could impersonate feed | Medium | Medium | Add stored_at timestamp to token payload and reject tokens presented more than 24 h after issue; add per-token nonce to prevent identical-token collisions |
Multiple phones register the same feed_id (malicious or key reuse) |
Low | Medium | POST /register is idempotent per pubkey; different pubkey producing the same SHA-256 truncation is SHA-256 collision — computationally infeasible; document key rotation flow |
Relay stores metadata (sender feed_id, recipient feed_id, timestamps) even if not message content — creates a social graph |
Medium | High | Document explicitly in threat model; for prototype scope acceptable; production path: use ephemeral pairwise identifiers per relay session (mirroring SimpleX queue model) |
| Pi power supply unreliability at events (voltage drop under USB-C power bank load) | Medium | Medium | Recommend official Pi USB-C PSU or quality power bank; systemd Restart=always recovers relay after brownout reboot |
| iOS hotspot entitlement adds App Store review complexity | Low | Low | For prototype, handle manually in TestFlight build; document production entitlement request process |
8. Two-Week Day-by-Day Build Plan
| Day | Goal | Deliverable | Dependencies |
|---|---|---|---|
| 1 | Pi hardware setup; WiFi hotspot verified | Phone connects to Pi hotspot; DHCP lease received; ping 192.168.4.1 succeeds |
Raspberry Pi 4/5, SD card, USB-C power, hostapd + dnsmasq installed |
| 2 | Relay server skeleton + SQLite schema | POST /register endpoint working; feeds, messages, tokens tables created in SQLite |
Go toolchain on dev machine; cross-compile to ARM64 verified |
| 3 | Message send + inbox endpoints | POST /messages/send stores ciphertext; GET /messages/inbox returns it; DELETE removes it |
Day 2 |
| 4 | E2E encryption on phone side | KeyBundle generated; XChaCha20-Poly1305 encrypt/decrypt roundtrip unit test passing on Android + iOS |
tink-android integrated (Android); CryptoKit usage confirmed (iOS) |
| 5 | Phone app end-to-end flow | Two simulator/device instances: Phone A registers, sends encrypted message, Phone B picks it up and decrypts successfully | Days 3 + 4 |
| 6 | WebSocket push notifications | Server pushes new_message event to connected clients; phone SDK subscribeInbox callback fires; end-to-end latency measured |
gorilla/websocket on server; URLSessionWebSocketTask / Ktor WS on phone |
| 7 | Message TTL expiry | Background goroutine deletes messages older than 72 h; unit test confirms expiry; DB size stays bounded after synthetic load | Day 3 |
| 8 | HTTPS with self-signed cert; cert trust | Self-signed cert generated; Go server serves HTTPS; Android NetworkSecurityConfig trusts cert; iOS custom URLSession delegate trusts cert |
openssl or mkcert for cert generation |
| 9 | Android NetworkRequest — force local WiFi |
App binds HTTP client to local WiFi network even when Android detects no internet; regression test: mobile data off, relay reachable | Android ConnectivityManager API; Day 5 |
| 10 | Social feed replication endpoints | POST /feeds/publish and GET /feeds/{feed_id}/since/{seq} working; integration test: A publishes 10 envelopes, B fetches all 10 |
Day 3; feed-format spec for envelope structure |
| 11 | systemd service; auto-start on boot | relay.service unit installed; Pi rebooted; relay starts automatically; integration test: messages persist across reboot |
Day 2 |
| 12 | Load test — 20 phones, 100 messages each | Load test script (Go or Python) simulating 20 concurrent clients; metrics recorded: p50/p99 latency, error rate, CPU/memory on Pi | Day 3; server metrics endpoint (GET /metrics or pprof) |
| 13 | Measurement run | Boot-to-ready time; message latency (A→relay→B); WiFi range test at 10 m, 20 m, 30 m, 50 m (with + without USB antenna) | Days 1, 5, 6 |
| 14 | Documentation + comparison write-up | Updated prototype findings section in this spec; side-by-side comparison of development complexity vs Prototype 2 (BLE messenger); "what comes next" notes | All previous days |