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:
mrambossek
2026-08-01 15:26:09 +02:00
co-authored by Claude Fable 5
parent 305d21f8a7
commit ac6c653115
4 changed files with 85 additions and 1 deletions
@@ -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")
}
}