Skip to the content.

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


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;
}

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
}

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, ...);
    }
}

Note on Cognito. The token in CrowdConnected.xml (sub d6b37d69-7518-4276-bfab-ded534ffdb0b, pool us-east-1_xsD5yIcIS, custom:appKey) is the Crowd Connected SDK’s own AWS Cognito identity, used to obtain credentials to write to Kinesis Firehose in us-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:

  1. The “technical identifier” is the user_id. app_user_id is 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.
  2. 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.
  3. login_user_id is 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:


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

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

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}

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:

US-region endpoints contacted this run (against the privacy policy’s “EU-only” claim):

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