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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
305d21f8a7
commit
ac6c653115
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user