From ac6c653115c923578efe15b6bdf6e8a8c4a6eab7 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 15:26:09 +0200 Subject: [PATCH] privacy: pseudonymize the whole ULA prefix, not just its tail Found in a real uploaded run from the phone: the server held fda1:3fb1:ff92:6696::2662 for a DNS server. The general IPv6 path keeps the leading two groups on purpose - for a global address that preserves the ISP allocation, which is the useful part - but for a ULA that passes through 32 of the 40 random bits of the global ID. A ULA looks like the v6 RFC1918 and the instinct is to treat it the same. It is not analogous, and the difference is the point: an RFC1918 prefix is shared by millions of networks and identifies none of them, while a ULA global ID is random and unique to one network by construction (RFC 4193). The prefix IS the identifier, so it was a network fingerprint surviving redaction. Pseudonymized as a unit now, so two addresses on one ULA subnet still share a pseudonymous prefix - "these hosts are on one network" survives, "this is that network" does not. RFC1918 stays readable, and the contrast is what justifies it; a test pins both halves. Co-Authored-By: Claude Fable 5 --- docs/build-status.md | 21 +++++++++ docs/measurement-schema.md | 2 +- .../kotlin/app/echo_lot/privacy/Anonymizer.kt | 20 +++++++++ .../app/echo_lot/privacy/AnonymizerTest.kt | 43 +++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/docs/build-status.md b/docs/build-status.md index c928c0d..e7b91a0 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -893,3 +893,24 @@ it — much of a document's payload lives in `evidence`/`metrics`/`params`, whic `JsonObject` by design and therefore *outside* any schema. A schema-driven anonymizer would have less coverage there than the name-plus-shape one now does, so the schema should be built for validation and external tooling, not as a replacement for the classifier. + +### ULA prefixes are pseudonymized whole (2026-08-01) +Spotted in a real uploaded run from the phone: the server had +`fda1:3fb1:ff92:6696::2662` for a DNS server. The general IPv6 path preserves the leading two +groups (deliberately — for a global address that keeps the ISP allocation, which is the +diagnostically useful part), and for a ULA that passed through **32 of the 40 random bits** of the +global ID. + +ULA looks like the v6 equivalent of RFC1918 and the instinct is to treat it the same. That +reasoning does not carry over, and the difference is the whole point: an RFC1918 prefix is shared +by millions of networks and identifies none of them, while a ULA global ID is random and unique to +one network by construction (RFC 4193). The prefix *is* the identifier — it is a network +fingerprint that was surviving redaction. + +Now pseudonymized as a unit, so two addresses on the same ULA subnet still land on the same +pseudonymous prefix: "these hosts are on one network" survives, "this is *that* network" does not. +Three tests, one of which uses the exact value observed on the wire. + +Worth recording as a reasoning trap: I had originally raised this as "ULA should probably be kept +verbatim, like RFC1918, for consistency". The surface analogy pointed the wrong way, and the +correct answer was the opposite. diff --git a/docs/measurement-schema.md b/docs/measurement-schema.md index c38ce3b..4cbee01 100644 --- a/docs/measurement-schema.md +++ b/docs/measurement-schema.md @@ -269,7 +269,7 @@ The JSON Schema (machine-readable companion, `measurement.schema.json`, generate | type | example fields | v2 anonymizer transform | |---|---|---| -| `ip4`, `ip6` | addresses, routes, hops, DNS answers | prefix-preserving pseudonymization, consistent per document; well-known/reserved ranges kept verbatim | +| `ip4`, `ip6` | addresses, routes, hops, DNS answers | prefix-preserving pseudonymization, consistent per document; well-known/reserved ranges kept verbatim. **Exception: ULA (`fc00::/7`) has its whole prefix pseudonymized as a unit.** It resembles RFC1918 but is not analogous: a ULA global ID is 40 random bits, unique to one network by construction (RFC 4193), so the prefix *is* the identifier, whereas `192.168.0.0/16` is shared by millions of networks and identifies none. Pseudonymizing it as a unit keeps "these hosts are on one subnet" while dropping "this is that subnet". | | `mac`, `bssid` | wifi, arp_watch | OUI kept, NIC part pseudonymized | | `fqdn` | DNS names, reverse lookups | per-label pseudonyms, public-suffix kept | | `ssid` | wifi | pseudonym | diff --git a/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Anonymizer.kt b/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Anonymizer.kt index a3bc0d0..76c3b21 100644 --- a/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Anonymizer.kt +++ b/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Anonymizer.kt @@ -188,6 +188,26 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) { // The unspecified address and the default route are not identities; mangling them would // make a routing table unreadable for no privacy gain. if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v + + // Unique local addresses (fc00::/7) need the *whole* prefix replaced, not the tail. + // + // They look like the v6 equivalent of RFC1918, and the first instinct is to keep them for + // the same reason: private, topological, says nothing about anyone. That reasoning does + // not carry over. An RFC1918 prefix is shared by millions of networks and identifies + // none of them; a ULA global ID is 40 *random* bits, unique to one network by + // construction (RFC 4193). It is a network fingerprint. Passing the leading groups + // through - which is what the general path does - leaked 32 of those 40 bits. + // + // The prefix is pseudonymized as a unit, so two addresses on the same ULA subnet still + // land on the same pseudonymous prefix. "These hosts are on one network" survives; + // "this is *that* network" does not. + if (v.startsWith("fc") || v.startsWith("fd")) { + val groups = v.substringBefore('%').split(":") + val prefix = pseudo("ula-prefix", groups.take(3).joinToString(":")) { it } + val host = pseudo("ula-host", v) { it } + return "fd${prefix.substring(0, 2)}:${prefix.substring(2, 6)}:${prefix.substring(6, 10)}" + + "::${host.substring(0, 4)}" + } val groups = v.substringBefore('%').split(":") if (groups.size < 3) return v val h = pseudo("ip6", value) { it } diff --git a/echolot-app/core-privacy/src/test/kotlin/app/echo_lot/privacy/AnonymizerTest.kt b/echolot-app/core-privacy/src/test/kotlin/app/echo_lot/privacy/AnonymizerTest.kt index 8456df3..48d6e7d 100644 --- a/echolot-app/core-privacy/src/test/kotlin/app/echo_lot/privacy/AnonymizerTest.kt +++ b/echolot-app/core-privacy/src/test/kotlin/app/echo_lot/privacy/AnonymizerTest.kt @@ -182,4 +182,47 @@ class AnonymizerTest { assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL)) assertEquals(PrivacyLevel.FULL, PrivacyLevel.fromWire("nonsense")) } + + // A ULA looks like the v6 RFC1918 and is not. Its global ID is 40 random bits, unique to one + // network by construction (RFC 4193), so the prefix IS the identifier - unlike 192.168.x, + // which millions of networks share. Passing the leading groups through leaked most of it. + @Test + fun ulaPrefixesArePseudonymizedWhole() { + val doc = json.parseToJsonElement( + """{"run":{"id":"r"},"networks":[{"link":{"dns":{"servers":["fda1:3fb1:ff92:6696::2662"]}}}]}""" + ).jsonObject + val out = flat(anon(PrivacyLevel.BALANCED, doc)) + assertFalse(out.contains("fda1"), "the ULA global ID survived: $out") + assertFalse(out.contains("3fb1"), "part of the ULA global ID survived: $out") + assertTrue(out.contains("fd"), "the result should still read as a ULA: $out") + } + + // Pseudonymizing the prefix as a unit keeps the one fact that is diagnostically useful: + // whether two addresses sit on the same network. + @Test + fun addressesOnOneUlaSubnetStayRelated() { + val doc = json.parseToJsonElement( + """{"run":{"id":"r"},"networks":[{"link":{"dns":{"servers":[ + "fda1:3fb1:ff92:6696::1","fda1:3fb1:ff92:6696::2","fdff:9999:8888:7777::1"]}}}]}""" + ).jsonObject + val servers = anon(PrivacyLevel.BALANCED, doc)["networks"]!!.jsonArray[0].jsonObject["link"]!! + .jsonObject["dns"]!!.jsonObject["servers"]!!.jsonArray.map { it.jsonPrimitive.content } + val prefixOf = { s: String -> s.substringBeforeLast("::") } + assertEquals(prefixOf(servers[0]), prefixOf(servers[1]), + "two addresses on one ULA subnet should share a pseudonymous prefix") + assertNotEquals(prefixOf(servers[0]), prefixOf(servers[2]), + "a different ULA network must not collide with the first") + } + + // RFC1918 stays readable, and this is the contrast that justifies it: a shared, meaningless + // prefix is topology; a unique random one is identity. + @Test + fun rfc1918StaysReadableUnlikeUla() { + val doc = json.parseToJsonElement( + """{"run":{"id":"r"},"networks":[{"link":{"dns":{"servers":["192.168.1.1","10.13.102.1"]}}}]}""" + ).jsonObject + val out = flat(anon(PrivacyLevel.BALANCED, doc)) + assertTrue(out.contains("192.168.1.1"), "RFC1918 should survive: $out") + assertTrue(out.contains("10.13.102.1"), "RFC1918 should survive: $out") + } }