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
@@ -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