Identity ↔ location correlation: forensic proof
App: TT Festival (com.appmiral.ttfestival v2.0.1, Appmiral platform + Crowd Connected location SDK)
Device: rooted Pixel (own device, owner-consented research)
Goal: Test the app-builder’s claim — relayed via Ranco van Bergeijk — that “Crowd Connected ontvangt geen persoonsgegevens … maar wel locatiegegevens en een technische identifier” (“Crowd Connected receives no personal data, only location plus a technical identifier”).
This document records (a) what we set out to prove, (b) what the evidence actually shows — including a correction to our own earlier hypothesis — and (c) why the builder’s statement is technically true but materially misleading under the GDPR.
0. TL;DR
- The location stream sent to Crowd Connected (CC) is keyed by
app_user_id=54b29c5a-94ec-42a6-b432-3b6c6e8ba358, plus the device aliaslogin_user_id= the same value. - That id is not a server-generated registration id. It is a client-side
UUID.randomUUID()created on first app launch, before/independent of any registration (AppmiralApp.getGeneratedUUID()). - The same id is stored on the device right next to
Geert de Graaf/geert@geertdegraaf.nl(TemplateStorage,ProfileIdentityProviderprefs), and is the value Appmiral uses asuser_id. - Therefore: CC holds
location ↔ 54b29c5a; Appmiral holds54b29c5a ↔ name/email. Joining the two re-identifies the person. The id is pseudonymous, not anonymous, and is personal data under GDPR Art. 4(1) + Recital 26. - The builder’s “only a technical identifier, no personal data” framing is the standard pseudonymisation-≠-anonymisation error. Whether by negligence or design, the conclusion (“not personal data”) is wrong.
- Empirically confirmed (clean-install, decrypted capture — §8): the same
app_user_idis sent not only to Crowd Connected (/prod/device-alias) but also to Appmiral’s own backend (app.appmiral.com/.../users/<id>) and packaged for AWS SNS. A real GPS position was captured flowing tofirehose.us-east-1, keyed by the CC deviceId that is aliased to that id. The id is a shared cross-service key, not a CC-only token.
1. What we set out to prove (the hypothesis)
Original working theory (from the email exchange): the correlation id is generated server-side at registration, tying email+name to the location stream.
This hypothesis is partly wrong, and we correct it here on purpose — because sending an inaccurate claim to the vendor would let them refute the entire report on a technicality. The real mechanism is different, and (for the privacy argument) stronger, not weaker.
2. The id is client-side, minted before registration
com/appmiral/AppmiralApp.java:382-392:
@Override
public String getGeneratedUUID() {
SharedPreferences sp = getSharedPreferences(KEY_UUID, 0); // KEY_UUID = "AppmiralUUID"
String s = sp.getString(KEY_UUID, null);
if (TextUtils.isEmpty(s)) {
s = UUID.randomUUID().toString(); // <-- generated on-device
SharedPreferencesUtilKt.setString(sp, KEY_UUID, s); // <-- persisted
}
return s;
}
- Generated by the client, on first launch, with no network call and no account.
- Persisted in
shared_prefs/AppmiralUUID.xml. On this device:54b29c5a-94ec-42a6-b432-3b6c6e8ba358. - It is the value Appmiral uses as
app_user_idanduser_id(seeTemplateStorage.xml, whereapp_user_id == user_id == 54b29c5a).
Implication: a user who grants location consent but never registers already has a stable, per-install id attached to their location. Registration does not create the id — it staples identity onto a tracking id that already exists and already follows the location stream. The id also survives logout (it is the install UUID), so logging out does not break the link.
3. The id is aliased to the CC location stream — unconditionally
com/appmiral/crowdconnected/CrowdConnected.java:95-109 (onStartUpSuccess):
public void onStartUpSuccess() {
CrowdConnected.enabled = true;
net.crowdconnected.android.core.CrowdConnected cc = ...getInstance();
if (cc != null) {
cc.setAlias("app_user_id", CoreApp.INSTANCE.from(context).getGeneratedUUID()); // ALWAYS
}
UserIdentity currentIdentity = IdentityProviderManager...getCurrentIdentity();
if (currentIdentity == null || (id = currentIdentity.getId()) == null || ...) {
return;
}
cc.setAlias("login_user_id", id); // IF logged in
}
app_user_idis sent to Crowd Connected as a device alias on every SDK startup, with no login condition.login_user_idis sent additionally when an identity exists.
CC’s setAlias posts to its device-alias API, binding the alias to the CC deviceId that tags every position record. (Captured live below.)
4. login_user_id is the same client UUID (the “profile” account is local)
com/appmiral/identityproviders/ProfileIdentityProvider.java:90-122:
private void loadIdentity() {
String firstName = prefs.getString("Profile.Firstname", null);
String lastName = prefs.getString("Profile.Lastname", null);
String email = prefs.getString("Profile.Email", null);
boolean marketing = prefs.getBoolean("Profile.MarketingConsent", false);
if (email != null) {
_identity = new UserIdentity(
"profile",
CoreApp.from(context).getGeneratedUUID(), // <-- id == client UUID 54b29c5a
null,null,null,null,
firstName, lastName, email, // <-- name/email kept locally
null, marketing, false, null, ...);
}
}
UserIdentity.getId()(→login_user_id) isgetGeneratedUUID()— the same54b29c5a-…. Soapp_user_idandlogin_user_idare identical values for the profile login. (This corrects our earlier audit wording that said location was tied to a separatelogin_user_id; the populated binding is the client UUID, surfaced under both alias keys.)ProfileIdentityProvider.authenticate()returnscallback.invoke(false, "No authentication support")— the “profile” login does not authenticate against any server. Name/email are an on-device intake form stored in SharedPrefs, keyed by the client UUID.
Note on Cognito. The token in
CrowdConnected.xml(sub d6b37d69-7518-4276-bfab-ded534ffdb0b, poolus-east-1_xsD5yIcIS,custom:appKey) is the Crowd Connected SDK’s own AWS Cognito identity, used to obtain credentials to write to Kinesis Firehose inus-east-1. It is CC infrastructure, not the festival user account. So the only server-minted id in play is CC’s AWS device identity — not a festival “registration id.”
5. Runtime evidence (this device)
| Artifact | Value | Source |
|---|---|---|
CC position deviceId |
3e119617-dab5-44b2-a081-a307fd64c806 |
decoded Firehose buffer (decode_buf.py) |
app_user_id alias |
54b29c5a-94ec-42a6-b432-3b6c6e8ba358 |
shared_prefs/CrowdConnected.xml (aliases) |
AppmiralUUID |
54b29c5a-… |
shared_prefs/AppmiralUUID.xml |
app_user_id == user_id |
54b29c5a-… |
shared_prefs/TemplateStorage.xml |
| Identity | Geert, de Graaf, geert@geertdegraaf.nl |
shared_prefs/ProfileIdentityProvider.xml |
| Alias send (live) | CORE: Setting alias app_user_id,54b29c5a-… → Sending alias immediately → POST device-alias/cached |
logcat |
| Position cadence | ~31 s median (inner ring), real GPS | 56 records over ~33 min (evidence/.../positions_buffer.raw) |
The join key 54b29c5a-… is literally stored in the same shared_prefs directory both (a) as the CC location alias and (b) next to the person’s name and email.
Correlation chain
GPS position record ──tagged by──▶ deviceId 3e119617-…
│
└─ CC device-alias API binds ──▶ app_user_id = login_user_id = 54b29c5a-…
│
Appmiral on-device + backend ──maps──▶ 54b29c5a-… ⇄ Geert de Graaf / geert@geertdegraaf.nl
Combine the two halves → a 30-second movement trail (inner ring) re-identified to a named person.
6. Is the builder’s statement a lie, or just wrong?
The statement “CC receives no personal data, only location + a technical identifier” is technically true in the narrow sense (CC is not handed the strings “Geert”/email) but materially misleading, for three reasons:
- The “technical identifier” is the user_id.
app_user_idis not an opaque, CC-only token — it is the same value Appmiral keys the named profile on. It is a join key, not an isolated technical detail. - Pseudonymous ≠ anonymous. GDPR Art. 4(1) + Recital 26: an identifier that singles out an individual is personal data when anyone (here, the festival/Appmiral, the controller) holds the additional info needed to re-identify. They do — the mapping is on the device and in their backend. So the CC-side data is personal data of the data subject, processed by a US sub-processor.
login_user_idis sent too. When logged in, the account identity id is also pushed to CC as a second alias — further undercutting “only a neutral technical identifier.”
Verdict: Not necessarily a deliberate lie — it is the standard pseudonymisation-≠-anonymisation conflation, plus a “CC doesn’t see the name, therefore it’s not personal” inference that is legally wrong. We cannot prove intent; we can prove the conclusion is incorrect. The honest framing for our reply: technically-true wording, false conclusion.
Their letter does concede the substantive points that matter: (a) the 60 km / two-days-before-to-one-day-after window is a configurable Crowd Connected default; (b) data is processed via AWS us-east-1 (US) — confirming our US-transfer finding and contradicting the “EU-only” privacy policy; (c) they will re-check the privacy statement. Those concessions should be acknowledged.
7. Corrections to our own prior audit wording
To keep the report bulletproof, fix these in the other docs:
- Old: “location is tied to your
login_user_id(the logged-in account).” New: location is tied toapp_user_id, a client-generated install UUID;login_user_id, when present, carries the same value. The point stands (location ↔ identity), but the mechanism is the install UUID, not a separate server account id. - Old / hypothesis: “the id is generated server-side at registration.” New: the festival id is client-side
UUID.randomUUID()on first launch; the only server-minted id is CC’s AWS Cognito SDK identity (us-east-1). Registration binds identity to the pre-existing client id; it does not generate it.
8. Live confirmation — executed clean-install packet capture (A/B)
The static proof above is conclusive on its own. We additionally executed a full clean-install capture end-to-end and decrypted the TLS traffic, to settle empirically the specific question “is that id sent only to Crowd Connected, or to other services too?”
Method (own rooted device, owner-consented): unmodified Play-signed app (no repackaging, so no
“you tampered with the APK” rebuttal). A mitmproxy CA was injected into the Android 16 APEX trust
store at runtime; device traffic was proxied through mitmdump and simultaneously captured with
tcpdump. The app exhibits no certificate pinning, so request URLs, headers and bodies are fully
readable. App data was wiped with pm clear immediately before the run. Artifacts (kept out of the
repo): flows_clean.mitm (≈31 MB), cap_clean.pcap (≈30 MB), onboarding screenshots.
8.1 A brand-new UUID is minted client-side, with no account
After pm clear, first launch wrote AppmiralUUID.xml:
app_user_id = 95260f99-8e80-4bba-83eb-017ae02edb84 (was 54b29c5a-… before the wipe)
A different, random value each install — confirming §2 (client-side UUID.randomUUID()), not a
server-issued registration id. Pre-consent, this id was transmitted nowhere (only Firebase
installations + Crashlytics settings were contacted, without the id). CC is genuinely consent-gated.
8.2 The same UUID sits next to the real name + email (local join)
After completing the (local, no-email-verification) profile form, TemplateStorage.xml on-device:
{"app_user_id":"95260f99-8e80-4bba-83eb-017ae02edb84",
"user_id":"95260f99-8e80-4bba-83eb-017ae02edb84",
"user_login_provider":"profile",
"user_firstname":"Geert",
"user_email":"geert@geertdegraaf.nl"}
app_user_id and user_id are the same value, stored beside the cleartext name and email.
This is the join key, on the device, exactly as §3 predicted.
8.3 Where the id actually goes (decrypted) — NOT only Crowd Connected
Searching every decrypted flow for 95260f99 (the app_user_id) and 60a67509 (the CC deviceId it
gets aliased to):
| Service (host) | Path | Carries 95260f99? |
|---|---|---|
Crowd Connected mobile.crowdconnected.com |
POST /prod/device-alias + /device-alias/cached |
yes — in body (aliases CC deviceId 60a67509 ↔ app_user_id 95260f99) |
Appmiral backend app.appmiral.com |
POST /function/events/ttfestival/editions/ttfestival2026/users/95260f99-… |
yes — in the URL itself |
Appmiral AWS SNS (push) sns.eu-west-1.amazonaws.com |
POST / |
id packaged in SnsApi.xml (Sns.Platform.Userdata), ARN …ttfestival2026… |
cognito-idp/identity.us-east-1 |
auth | CC’s own US Cognito identity (no app_user_id) |
firehose.us-east-1.amazonaws.com |
PutRecordBatch |
geo positions, keyed by CC deviceId 60a67509 (aliased to the id above) |
Answer to the question: no. app_user_id is not a Crowd-Connected-only token. The exact same
value is also POSTed to Appmiral’s own backend (app.appmiral.com/.../users/<id>) and packaged for
AWS SNS push. It is a shared cross-service identity key.
8.4 The geo sink, decrypted
One position record POSTed to firehose.us-east-1 (stream colocator-v3-positions-firehose),
base64-decoded:
60a67509-ae3a-4fd3-bde3-7e180bdd0d53,geo,3821186.114,438780.175,53.0047049,6.550485,
e52e1cbe-0260-4469-a68c-b0570da3f779,0.0,0.0,0.0,null,0,use,0,1782254990445,2,0,dd54d39d
60a67509-…= CC deviceId — the valuedevice-aliasties toapp_user_id 95260f99(§8.3).53.0047049, 6.550485= real GPS (≈1.2 km N of Assen centre),dd54d39d= CC appKey, plus epoch-ms timestamp.
The full chain, observed in one clean install:
GPS (53.0047,6.5505) → Firehose, keyed by deviceId 60a67509 → device-alias: 60a67509 ↔ app_user_id 95260f99 → app.appmiral.com registers 95260f99 → on device, 95260f99 = Geert / geert@geertdegraaf.nl.
Location is therefore re-identifiable to a named person, and the id reaches Appmiral as well as
Crowd Connected — empirically, not just by code reading.
(Cadence note: only one position was emitted over ~3 min because the test location sits in the outer
60 km zone, not the 820 m inner ring; outer-zone sampling is slow. The ~30 s inner-ring cadence is
established separately from the SDK config in crowd-connected.md.)
9. Outer-zone behaviour, measured: one-hour Vries capture (kill-switch active)
§8 proved the id chain but only saw a single position over ~3 min and noted “outer-zone sampling is slow” without measuring it. This run measures it precisely and captures a state the original audit never saw: the server-side collection switch turned ON.
9.1 Method
- Clean reinstall of the same APKs (base + 2 splits pulled from the device). The app is wrapped
in Google Play Licensing (LVL) via
com.pairip.licensecheckand refuses to run (kills itself inLicenseActivity) unless it was installed by Play. Reinstalling withpm install -i com.android.vending(Play installer attribution) restores LVL and the app launches. (This is itself notable: the build ships a Google Play anti-tamper/licensing wrapper.) - Fresh registration through the in-app intake (
Geert/de Graaf/geert@geertdegraaf.nl), background location granted “Allow all the time”. - GPS mocked to Vries (
53.0775, 6.5836, ~9 km N of Assen) via Android AOSP test providers (cmd location providers …), pushed every 3 s. Vries sits inside the 60 km outer zone but outside the 820 m inner ring. - Full TLS interception (mitmproxy CA injected into the Android 16 APEX conscrypt store; no cert
pinning) +
tcpdump. Capture ran 10:00 → 11:05 (~1 h 6 m). - Artifacts:
capture/flows_outer_final.mitm(decrypted),capture/cap_outer.pcap,capture/hour_monitor.log, intake screenshotscapture/outer_*.png.
9.2 The collection switch was active during this run
GET mobile.crowdconnected.com/prod/settings/dd54d39d/<deviceId> returned, for the TT Festival
appKey dd54d39d:
{"geo":{"frequency_1":30,"frequency_2":1800,
"zones":[{"lat":52.994439,"lng":6.563694,"radius_1":820,"radius_2":60000,
"zoneID":"68d6c16e-2d07-4779-920d-3ee95a559fa2"}],
"enabled":true},
"pollingFrequency":120,
"dataSending":{"positionData":true,"rawData":false,"batteryLevel":true},
"active":true}
"active": true— collection is on. The original audit observedactive:false; the remote kill-switch is therefore demonstrably flipped server-side, exactly as the builder said it could be (“op afstand geactiveerd … zonder nieuwe app-versie”). Here it was active.- Zone = Assen centre
52.994439, 6.563694,radius_1 = 820 m(inner),radius_2 = 60 000 m(the 60 km zone),frequency_1 = 30 s(inner),frequency_2 = 1800 s(outer).
9.3 The mocked GPS reached Crowd Connected — outer-zone cadence is ~30 min
Crowd Connected reads location through Play-Services FusedLocationProviderClient; the AOSP
test-provider mock fed it correctly. Three — and only three — geo positions were PutRecorded to
firehose.us-east-1.amazonaws.com (US Virginia, stream colocator-v3-positions-firehose)
over the hour:
| UTC time | offset | deviceId | lat, lon | appKey |
|---|---|---|---|---|
| 07:54:00Z | +0.0 min | bdb986c1-… |
53.0775, 6.5836 | dd54d39d |
| 08:25:52Z | +31.9 min | bdb986c1-… |
53.0775, 6.5836 | dd54d39d |
| 08:55:53Z | +61.9 min | bdb986c1-… |
53.0775, 6.5836 | dd54d39d |
Inter-position gaps ≈ 1800 s. Outer-zone cadence = one position every ~30 minutes
(frequency_2), vs the 30 s inner-ring cadence (frequency_1). Every position is the mocked Vries
coordinate, confirming the location source held for the full hour. Raw decoded record:
bdb986c1-a000-4ad2-90e1-28f29e912d87,geo,3814467.187,440242.501,53.0775,6.5836,
e52e1cbe-0260-4469-a68c-b0570da3f779,0.0,0.0,0.0,null,0,use,0,1782287640191,2,0,dd54d39d
9.4 Same id chain, new install — and the US endpoints
The new install minted a fresh client UUID and CC deviceId, and the alias call linked them again:
POST mobile.crowdconnected.com/prod/device-alias→record: bdb986c1-a000-4ad2-90e1-28f29e912d87,app_user_id,853850bc-4e59-441c-a2f7-0b9c59e8710c,dd54d39d- i.e. CC deviceId
bdb986c1…↔app_user_id 853850bc…(the value stored on-device next toGeert/geert@geertdegraaf.nl), plus a second aliassns_user_id →arn:aws:sns:eu-west-1:…:endpoint/GCM/appm-0001-tama-framework-v3-1-0-ttfestival2026-GCM-prod/….
US-region endpoints contacted this run (against the privacy policy’s “EU-only” claim):
firehose.us-east-1.amazonaws.com— the geo position sink (above)cognito-identity.us-east-1.amazonaws.comandcognito-idp.us-east-1.amazonaws.com— CC’s auth
9.5 Incidental finding: the app bundles a second event’s tracking config
The same binary also polls Crowd Connected appKey 36fe15a0 = dutchgp2025, zone
52.387479, 4.544506 (Zandvoort area), radius_2 = 34 000, with its own device-alias
(deviceId 382c3ab8… ↔ app_user_id bcc5c494…) and …dutchgp2025… SNS ARN. The TT Festival app
is one instance of the multi-event “appm-0001-tama-framework-v3” platform and ships more than one
event’s location-tracking configuration.
9.6 What this adds to §8
- The 60 km zone, the 820 m inner ring, and the 30 s / 30 min dual cadence are now confirmed live from the server config and from observed Firehose timing, not just from decompiled code.
- The location→US-Firehose→deviceId→
app_user_id→name/email chain reproduces on a fresh install. - It was captured while the system was
active:true— i.e. actually collecting — closing the “but it was switched off during your audit” caveat for this measurement.