Files
VIGIL/docs/detection-rotation-clone.md
T
Kara Zajac 817a4ac1c9 VIGIL 0.1.0 — temporal counter-tracking (prototype)
Android app that detects personal item-trackers (AirTag, Tile, Samsung SmartTag, Google Find My Device / DULT) following the user over time. Sibling to OVERWATCH: OVERWATCH is spatial, VIGIL is temporal.

- BLE scan + per-ecosystem wire-format parsing (Apple 0x004C/0x12, FMDN 0xFEAA, Samsung 0xFD5A, Tile 0xFEED/FEEC, DULT 0xFCB2)
- Room temporal store (14-day sightings), co-movement evaluator with RSSI proximity gate
- Allowlist + learned offline baseline (auto-trust household tags)
- Foreground service, Compose UI (Catppuccin Mocha)
- Privacy: NO INTERNET permission — fully on-device
- Reuses OVERWATCH Gradle setup + committed debug keystore
- docs/detection-rotation-clone.md: design for the rotation-clone presence engine (#1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 00:11:00 -04:00

368 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VIGIL — Detecting Key-Rotating Tracker Clones by Presence, Not Identity
**Design document · the core novel detection algorithm (problem #1) · v1 · 2026-07-14**
Target: on-device Android (Kotlin), no server. Slots into VIGIL's `scan/` +
`detect/` architecture; the co-movement v1 in `detect/CoMovementEvaluator.kt`
ships now, the presence engine below (`detect/PresenceEngine.kt`) is next.
---
## 0. Thesis (TL;DR)
Every deployed anti-stalking detector — Apple *Tracker Detect*, iOS *Find My*
unwanted-tracking alerts, and TU-Darmstadt's **AirGuard** — keys on **device
identity**: it counts repeat sightings of one BLE identifier and alarms at a
threshold (AirGuard: the *same* identifier seen **≥ 3 times across a location
change**). A key-rotating clone (Positive Security's **Find You**: an ESP32
cycling **2000** Find My public keys, a new one every **~30 s**) presents each
sighting as a brand-new device, so the repeat count never accumulates. In the
authors' 5-day carry test, **no tool raised any passive alert.**
VIGIL's differentiator: **stop tracking identities, start tracking the *presence*.**
Ask *"is there one physical radio maintaining an unbroken, close-range, co-moving
RF presence, even though no single identifier survives?"* This is detectable
because the attack, to function as a tracker, must emit an **unbroken stream of
separated-state beacons at roughly stable close-range RSSI that moves with the
victim across locations where the ambient tracker population fully turns over.**
Identity churn cannot hide that.
The most exploitable fact: **the standards mandate slow rotation.** IETF DULT,
Apple, and Google all specify that a separated accessory rotates its address/key
**once per 24 hours** (AirTag: static MAC+key for 24 h, re-rolling at 04:00; DULT
accessory-protocol: rotate every 24 h; Google FMDN: 24 h in unwanted-tracking
mode). A real separated tracker following you produces **one identity for a full
day**. A clone rotating every 30 s runs **~2880× faster than any spec-compliant
device** — not "suspicious," but *categorically impossible* for a legitimate
separated tracker. VIGIL anomaly-detects against that standardized baseline.
---
## 1. Formalization: the observable signal
### 1.1 Per-sighting record
```
Sighting {
t, wallT : Long // monotonic + epoch ms
eco : Ecosystem // APPLE_FM | GOOGLE_FMDN | SAMSUNG_ST | TILE
eid : ByteArray // ephemeral identity (§1.2) — the churning label
separated : Boolean // separated/lost-state beacon? (§1.3)
rssi : Int // dBm
txPower : Int? // advertised TX power if present (path-loss norm.)
lat,lon : Double; gpsAcc, speed : Float // last fused fix + speed
}
```
### 1.2 The identity that churns (`eid`)
| Ecosystem | `eid` | Spec rotation (separated) |
|---|---|---|
| Apple Find My | 28-byte public key (MAC ⧺ payload key bytes); MAC as cheap proxy | **24 h** (re-roll 04:00) |
| Google FMDN | rotating EID (`Rx`) | ~1024 s normal; **24 h** unwanted-tracking mode |
| Samsung SmartTag | rotating region of offline-finding payload | ~15 min24 h |
| Tile | rotating id (but MAC is static) | vendor-specific |
Structural fact for Apple: **the MAC is derived from the key, so both rotate
together** — you cannot even fall back to MAC-tracking. Identity churn is total.
### 1.3 The `separated` predicate
Only separated-state beacons are relevant: a tracker with its owner is
nearby/connectable (no threat). A tracker following you without its owner is by
definition separated and broadcasts its full key every 2 s. VIGIL filters to
`separated == true` — this cuts noise and is exactly the population the clone
must join.
### 1.4 The three core computable quantities
Close band: `rssi ≥ R_close` (starter **65 dBm**).
**(a) Novel-identifier churn rate `λ_novel`.** Keep a set `Seen` of `eid`s over
horizon `H` (30 min). A sighting is *novel* if `eid ∉ Seen`.
`λ_novel(t) = |distinct eid first seen in (tW, t]| / W` (W = 5 min).
Real separated tracker → ~0.0007/min. Find You @30 s → ~2/min. A ~2800× gap.
**(b) Presence continuity `PC`** — is the close band *continuously occupied*,
regardless of *who* occupies it?
`o(t)=1 iff ∃ separated sighting in (t−δ,t] with rssi≥R_close` (δ = 10 s);
`PC(W) = duty cycle of o`; `Runmax = longest run with no gap > g_break` (20 s).
Clone → `PC≈1`, `Runmax` = whole session. A crowd also keeps it occupied, so
`PC` alone is insufficient — it must be tied to coherence (§1.5) and co-movement.
**(c) Co-movement `CM`** — did the presence follow you across a **population
turnover**? The physics that makes this a *hard* discriminator — the **ambient
dwell bound**: a fixed tag or a passer stays in your close band (radius R≈10 m)
for at most `dwell ≤ 2R / v_rel`. At 13 m/s (driving) → ~1.5 s; walking → ~14 s;
only a co-*moving* emitter (v_rel≈0) has unbounded dwell. So **conditioned on
measured v̄ > 0, a close-band presence whose dwell ≫ 2R/v̄ is co-moving by
construction.** Over K ≥ 3 non-adjacent cells at speed, no single ambient tag
can appear — the presence is provably not any one ambient source.
### 1.5 The unifying reframe: presence tracking without identity
Discard `eid` as a *tracking* key; keep it only as a *novelty* signal. Treat
close-band separated sightings as points in `(t, rssi)` and ask: does **one
continuous, low-variance trajectory** explain them (one radio), or a **diffuse
high-variance cloud** (a crowd)? At each **handover** (close-band identity
changing) test continuity:
```
seamless handover: |rssi_new ewma_rssi| ≤ ε_rssi (8 dB) AND Δt ≤ g_seam (6 s)
```
A rotating clone emits a **long chain of seamless handovers into novel
identities** — the radio never leaves, only its name does. A crowd emits gappy,
RSSI-discontinuous handovers. This **seamless-novel-handover chain** is the
algorithmic heart of VIGIL and the quantity no identity churn can fake while
remaining a functioning tracker.
---
## 2. Algorithm
### 2.1 Weighing four candidate approaches
| Approach | Catches clone? | Cost | FP in dense RF | Verdict |
|---|---|---|---|---|
| Sliding-window novelty count | Yes | Trivial | Bad (busy street churns) | cheap *trigger* only |
| CUSUM change-point on `λ_novel` | Yes, bounded latency | Trivial | Better (regime vs baseline) | good *trigger*, must gate |
| Density on `(rssi,t)` | Yes | Heavier | Separates single-radio track from crowd | do the online single-track version |
| Presence-occupancy + seamless chain | Yes | Moderate O(1)/sighting | **Best** (coherence + co-move) | **core confirmer** |
None alone is deployable. The design is a **hybrid pipeline**: a cheap CUSUM
churn *trigger* wakes an expensive presence-coherence *confirmer*, which only
alarms behind a *co-movement gate*.
### 2.2 Pipeline
```
Stage A CUSUM on λ_novel (always on, O(1)) → trigger
Stage B presence-track confirmer (armed on trigger): identity-agnostic single
(t,rssi) track; counts novel *seamless* handovers, RSSI residual σ,
presence-run duration → candidate
Stage C co-movement gate + scoring: requires Dnet, distinct cells, v̄>0 → 0100
```
### 2.3 / 2.4 State + update rule
```kotlin
class ChurnCusum(val mu0: Double, val sigma0: Double, val k: Double, val h: Double) {
var S = 0.0
fun update(novelInLastMin: Int): Boolean { // per minute
val z = (novelInLastMin - mu0) / sigma0
S = maxOf(0.0, S + z - k)
return S > h // sustained regime shift
}
}
data class PresenceRun(
var startT: Long, var startLat: Double, var startLon: Double,
var lastT: Long, var lastEid: ByteArray,
var ewmaRssi: Double, var ewmaAbsResid: Double, // coherence
var handovers: Int, var seamlessNovelHandovers: Int,
var cells: MutableSet<Long>, var dnet: Double, var vSum: Double, var vN: Int,
var active: Boolean
)
fun onSeparatedSighting(s: Sighting, st: State) {
val novel = !st.seen.contains(s.eid); if (novel) st.seen.add(s.eid)
st.novelThisMinute += if (novel) 1 else 0
if (s.rssi < R_CLOSE) return // only close band builds presence
val p = st.run
if (!p.active || s.t - p.lastT > G_BREAK) { // (re)start a run
if (p.active) evaluate(p, st)
st.run = PresenceRun(s.t, s.lat, s.lon, s.t, s.eid, s.rssi.toDouble(), 0.0,
0, 0, mutableSetOf(cellOf(s.lat,s.lon)), 0.0, s.speed.toDouble(), 1, true)
return
}
val resid = s.rssi - p.ewmaRssi
if (!s.eid.contentEquals(p.lastEid)) { // handover
p.handovers++
if (abs(resid) <= EPS_RSSI && (s.t - p.lastT) <= G_SEAM && novel)
p.seamlessNovelHandovers++ // ← THE tell
}
p.ewmaRssi = ALPHA*s.rssi + (1-ALPHA)*p.ewmaRssi
p.ewmaAbsResid = ALPHA*abs(resid) + (1-ALPHA)*p.ewmaAbsResid // ≈ RSSI σ
p.lastT = s.t; p.lastEid = s.eid
p.cells.add(cellOf(s.lat,s.lon)); p.dnet = haversine(p.startLat,p.startLon,s.lat,s.lon)
p.vSum += s.speed; p.vN++
scoreLive(p, st)
}
fun scoreLive(p: PresenceRun): Int {
val durMin = (p.lastT - p.startT)/60_000.0; val vbar = p.vSum/p.vN
val moving = vbar > V_MIN // 1 m/s
val comoves = p.dnet >= D_NET && p.cells.size >= K_CELLS // 500 m & 3 cells
if (!(moving && comoves)) return 0 // Stage-C gate: silent
val coherent = p.ewmaAbsResid <= SIGMA_MAX // single-radio tightness
val churnAnom = (p.seamlessNovelHandovers/max(durMin,1e-3)) >= RHO_NOVEL
val seamlessFrac = if (p.handovers>0) p.seamlessNovelHandovers.toDouble()/p.handovers else 0.0
var sc = 0.0
sc += clamp(durMin/T_MIN,0.0,1.0)*30
sc += clamp(p.seamlessNovelHandovers/N_HANDOVER,0.0,1.0)*30
sc += clamp(p.cells.size/(2.0*K_CELLS),0.0,1.0)*20
sc += (if (coherent) 1.0 else 0.3)*(if (churnAnom) 1.0 else 0.5)*(0.5+0.5*seamlessFrac)*20
return sc.roundToInt()
}
```
Tiers (VIGIL 4-tier): `<40` clear · `4069` WATCHING (soft) · `7084` probable
clone · `85+` persisted across ≥ K_CELLS cells / mode change.
### 2.5 Starter parameters
`R_CLOSE 65 dBm · δ 10 s · G_BREAK 20 s · G_SEAM 6 s · EPS_RSSI 8 dB · SIGMA_MAX
8 dB · H 30 min · W 5 min · CUSUM k 0.5, h 5, μ0/σ0 = rolling personal baseline ·
α 0.3 · ρ (min novel-seamless rate) 0.7/min · D_NET 500 m · K_CELLS 3×250 m ·
V_MIN 1 m/s · T_MIN 10 min (ORANGE)/3 min (WATCHING) · N_HANDOVER 12`.
### 2.6 Fit to VIGIL
Runs entirely on-device over the existing scan path. `scan/TrackerParser.kt`
already emits per-ecosystem separated sightings; add speed to the record via
`LocationProvider`. New `detect/PresenceEngine.kt` holds `ChurnCusum` +
`PresenceRun` + the novelty set as a pure streaming function (offline-testable,
§5). Keep a bounded ring buffer over horizon `H`; multi-session mode (§4.3) is
opt-in.
---
## 3. False positives (the crux)
The enemy is dense urban RF where legitimate separated-Find-My churn is naturally
high. FPs are where this feature lives or dies.
| Scenario | Fires | Killed by |
|---|---|---|
| Dense-urban walk / festival | high λ_novel, PC≈1 | **seamless chain fails** — a crowd is many radios ⇒ gappy, RSSI-discontinuous handovers, high ewmaAbsResid |
| Apartment / at home | high PC, stationary | **co-movement gate** (v̄≈0, 1 cell) ⇒ score 0 |
| One stranger's real AirTag on your bus | co-moving, close, stable | not the clone path (it has 1 id/24 h) — handled by the identity detector + dismiss/whitelist UX |
| Busload of strangers' real tags | co-moving, PC≈1 | **novel-churn ≈ 0** (stable set of stable ids) ⇒ clone path silent |
| Rush-hour subway w/ heavy boarding churn | A + B + partial C | **the genuinely hard case — §3.3** |
**Discriminators, strongest first:** (1) seamless-novel-handover chain — only one
physical emitter yields `|Δrssi|≤8 dB, Δt≤6 s` across identity changes,
repeatedly; (2) co-movement across turnover (dwell bound); (3) RSSI coherence;
(4) stationary suppression (removes the whole home/apartment class).
**Where it breaks: live rush-hour transit.** People board/alight with real
separated tags, so λ_novel is high, the vehicle co-moves you across cells, PC≈1 —
three signals align. What still separates it: those tags are at many distances
(high ewmaAbsResid, low seamless fraction) and turnover is bursty/stop-synced, not
a 30 s metronome. The decisive gate: **persistence across a mode change** — a
transit crowd cannot follow you *off the train and down the street*. Requiring the
coherent co-moving presence to survive a context transition drops transit FPs to
~0, **at the cost of latency**. That is the honest central trade: lower transit FP
is bought with detection latency. The MVP keeps transit at WATCHING (silent) and
escalates only after a mode change or ≥ T_MIN beyond the transit segment.
---
## 4. Robustness
### 4.1 The rotation-rate squeeze (the strongest claim)
VIGIL runs the **identity path** (classic AirGuard-style; catches any key held
> ~35 min, seen ≥3× across a location change) **and** the **churn path** (this
doc; catches novel-seamless rates above ~0.51/min) together:
```
rotation period: 30s ──── 2min ──── 5min ─────────── 24h
└ churn path ┘
└ overlap seam ┘
└────── identity path ──────┘
```
**No rotation period evades both.** Fast → churn path; slow → identity path;
middle → both. Moreover the presence-continuity/co-movement path is
rotation-rate-agnostic — slowing rotation lowers λ_novel but does nothing to the
unbroken coherent co-moving close-band track. **The attacker's only real lever is
continuity, not rate.**
### 4.2 Adaptations
| Move | Effect | Response | Residual |
|---|---|---|---|
| Slower rotation | λ_novel drops | identity path re-engages | high (squeeze) |
| Variable TX power | RSSI jitter weakens coherence | normalize by advertised txPower; lean on continuity+co-move+churn | medhigh |
| Intermittent duty-cycling | gaps reset runs | reacquire logic: a gap < G_reacq (3 min) resuming at coherent RSSI along your path = same presence; accumulate across gaps | medium (this is the biting attack) |
| Blend into transit | crowd cover | mode-change gate (§3.3) | medium (needs latency) |
### 4.3 The true floor
Every evasion degrades the attacker's own tracker (slower/duty-cycled = fewer
fixes; lower power = shorter range). Below some duty cycle (a few random beacons
per 15 min, random power) the emitter is information-theoretically
indistinguishable from sparse ambient pass-bys **in a single session**, and
detection must move to **cross-session recurrence** — the same anomalous weakly-
co-moving presence recurring day after day along your routes (a per-day coarse
RSSI/timing fingerprint matched across sessions). That trades **days of latency**
and needs opt-in persistent storage. **The honest floor: a patient, low-duty-cycle
adaptive attacker pushes VIGIL from minutes-latency single-session detection to
days-latency multi-session detection.** Say so; don't claim an impossible
guarantee.
---
## 5. Validation plan (on-device Kotlin, no server, no real stalker)
1. **Red-team emitter.** Parameterized OpenHaystack / Find You ESP32 clone
(base: `positive-security/find-you`), sweeping `rotationPeriod ∈ {30 s … 24 h}`,
`txPower ∈ {fixed, ±k dB}`, `dutyCycle ∈ {100%, 50%, 20%, bursty}`,
`keyPoolSize`. Carried, it is a real co-moving emitter → ground-truth-positive
without endangering anyone.
2. **Ambient baselines** (ground-truth-negative), by class: rural drive · suburban
· dense-urban walk · bus · subway/rush-hour · highway · apartment · café. Paired
runs (same route, emitter on/off).
3. **Record/replay harness** — make `PresenceEngine` a pure
`List<Sighting> → List<ScoredEvent>`; record raw sightings once, replay JSONL to
A/B thresholds deterministically offline (unit tests / in-app debug screen).
Synthesize attack-in-context by merging a clean run with an emitter-only trace.
4. **Metrics:** TPR @ ≤ 1 FP / active-carry-day per class; detection latency
(attach → first ORANGE), target ≤ 1015 min for default Find You; ROC/AUC;
robustness curves (TPR vs rotationPeriod / txPower / dutyCycle); ablations
(churn-only → +coherence → +co-move gate → full).
---
## 6. Verdict
**Feasible enough to be VIGIL's headline — for a precisely scoped claim, with
honest caveats.**
Real, not hype: the attack must maintain a continuous coherent co-moving presence
to function; the standards mandate 24 h rotation, giving a *categorical* anomaly
baseline; the identity×churn squeeze leaves no safe rate; and VIGIL strictly
dominates incumbents on this attack (they raise zero alerts on Find You, VIGIL
raises one). Research-grade long shots: robust low-FP detection in live rush-hour
transit, and a patient low-duty-cycle adaptive attacker (provably forced to
multi-session/days latency).
**Minimum viable version (ship first).** Target the published Find You config
(30 s, fixed power, ~100% duty) — the exact attack that renders AirGuard silent:
Stage A CUSUM trigger; Stage B presence track counting novel *seamless*
handovers with RSSI coherence; Stage C hard co-movement gate (ORANGE after ≥10
min coherent co-moving presence, ≥12 novel seamless handovers, ≥3 cells while
v̄>1 m/s; RED after a cell/mode change). Run the classic identity detector in
parallel. New tier distinct from the identity-based alert, with a dismiss/whitelist
UX for the stranger-on-your-commute case. Detects the real-world clone in ~1015
min at a near-zero FP rate outside rush-hour transit — and is honest about the
adaptive-attacker floor.
---
## 7. References
- Positive Security, "Find You: Building a stealth AirTag clone" — https://positive.security/blog/find-you · https://github.com/positive-security/find-you
- Heinrich et al., "Who Can Find My Devices?" PETS 2021 — https://petsymposium.org/popets/2021/popets-2021-0045.pdf
- Heinrich et al., "AirGuard", WiSec 2022 — https://arxiv.org/pdf/2202.11813
- A. Catley, AirTag reverse engineering — https://adamcatley.com/AirTag.html
- IETF DULT — accessory-protocol + threat-model — https://datatracker.ietf.org/wg/dult/
- Google Fast Pair FMDN spec — https://developers.google.com/nearby/fast-pair/specifications/extensions/fmdn
- SEEMOO OpenHaystack — https://github.com/seemoo-lab/openhaystack
- E. S. Page, "Continuous Inspection Schemes" (Biometrika 1954) — CUSUM; Basseville & Nikiforov, *Detection of Abrupt Changes* (1993)
- Datar/Gionis/Indyk/Motwani sliding-window distinct counting; Flajolet et al. HyperLogLog
- Becker et al., "Tracking Anonymized Bluetooth Devices", PETS 2019
- DP-3T / Google-Apple Exposure Notification — RSSI attenuation-bucket presence reasoning