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
|
`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
|
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.
|
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 |
|
| 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 |
|
| `mac`, `bssid` | wifi, arp_watch | OUI kept, NIC part pseudonymized |
|
||||||
| `fqdn` | DNS names, reverse lookups | per-label pseudonyms, public-suffix kept |
|
| `fqdn` | DNS names, reverse lookups | per-label pseudonyms, public-suffix kept |
|
||||||
| `ssid` | wifi | pseudonym |
|
| `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
|
// The unspecified address and the default route are not identities; mangling them would
|
||||||
// make a routing table unreadable for no privacy gain.
|
// make a routing table unreadable for no privacy gain.
|
||||||
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
|
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(":")
|
val groups = v.substringBefore('%').split(":")
|
||||||
if (groups.size < 3) return v
|
if (groups.size < 3) return v
|
||||||
val h = pseudo("ip6", value) { it }
|
val h = pseudo("ip6", value) { it }
|
||||||
|
|||||||
@@ -182,4 +182,47 @@ class AnonymizerTest {
|
|||||||
assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL))
|
assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL))
|
||||||
assertEquals(PrivacyLevel.FULL, PrivacyLevel.fromWire("nonsense"))
|
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