diff --git a/irc/client.go b/irc/client.go index 2ed9bcc8..a9436504 100644 --- a/irc/client.go +++ b/irc/client.go @@ -70,6 +70,7 @@ type Client struct { preregNick string proxiedIP net.IP // actual remote IP if using the PROXY protocol rawHostname string + cloakedHostname string realname string realIP net.IP registered bool @@ -215,6 +216,7 @@ func RunNewClient(server *Server, conn clientConn) { session.realIP = utils.AddrToIP(remoteAddr) // set the hostname for this client (may be overridden later by PROXY or WEBIRC) session.rawHostname = utils.LookupHostname(session.realIP.String()) + client.cloakedHostname = config.Server.Cloaks.ComputeCloak(session.realIP) if utils.AddrIsLocal(remoteAddr) { // treat local connections as secure (may be overridden later by WEBIRC) client.SetMode(modes.TLS, true) @@ -812,7 +814,10 @@ func (client *Client) updateNick(nick, nickCasefolded, skeleton string) { func (client *Client) updateNickMaskNoMutex() { client.hostname = client.getVHostNoMutex() if client.hostname == "" { - client.hostname = client.rawHostname + client.hostname = client.cloakedHostname + if client.hostname == "" { + client.hostname = client.rawHostname + } } cfhostname, err := Casefold(client.hostname) @@ -831,6 +836,7 @@ func (client *Client) AllNickmasks() (masks []string) { nick := client.nickCasefolded username := client.username rawHostname := client.rawHostname + cloakedHostname := client.cloakedHostname vhost := client.getVHostNoMutex() client.stateMutex.RUnlock() username = strings.ToLower(username) @@ -849,6 +855,10 @@ func (client *Client) AllNickmasks() (masks []string) { masks = append(masks, rawhostmask) } + if cloakedHostname != "" { + masks = append(masks, fmt.Sprintf("%s!%s@%s", nick, username, cloakedHostname)) + } + ipmask := fmt.Sprintf("%s!%s@%s", nick, username, client.IPString()) if ipmask != rawhostmask { masks = append(masks, ipmask) diff --git a/irc/cloak_test.go b/irc/cloak_test.go new file mode 100644 index 00000000..49271fdd --- /dev/null +++ b/irc/cloak_test.go @@ -0,0 +1,99 @@ +// Copyright (c) 2019 Shivaram Lingamneni +// released under the MIT license + +package irc + +import ( + "net" + "testing" +) + +func easyParseIP(ipstr string) (result net.IP) { + result = net.ParseIP(ipstr) + if result == nil { + panic(ipstr) + } + return +} + +func cloakConfForTesting() CloakConfig { + config := CloakConfig{ + Enabled: true, + Netname: "oragono", + Secret: "_BdVPWB5sray7McbFmeuJL996yaLgG4l9tEyficGXKg", + CidrLenIPv4: 32, + CidrLenIPv6: 64, + NumBits: 80, + } + config.postprocess() + return config +} + +func TestCloakDeterminism(t *testing.T) { + config := cloakConfForTesting() + + v4ip := easyParseIP("8.8.8.8").To4() + assertEqual(config.ComputeCloak(v4ip), "d2z5guriqhzwazyr.oragono", t) + // use of the 4-in-6 mapping should not affect the cloak + v6mappedIP := v4ip.To16() + assertEqual(config.ComputeCloak(v6mappedIP), "d2z5guriqhzwazyr.oragono", t) + + v6ip := easyParseIP("2001:0db8::1") + assertEqual(config.ComputeCloak(v6ip), "w7ren6nxii6f3i3d.oragono", t) + // same CIDR, so same cloak: + v6ipsamecidr := easyParseIP("2001:0db8::2") + assertEqual(config.ComputeCloak(v6ipsamecidr), "w7ren6nxii6f3i3d.oragono", t) + v6ipdifferentcidr := easyParseIP("2001:0db9::1") + // different CIDR, different cloak: + assertEqual(config.ComputeCloak(v6ipdifferentcidr), "ccmptyrjwsxv4f4d.oragono", t) + + // cloak values must be sensitive to changes in the secret key + config.Secret = "HJcXK4lLawxBE4-9SIdPji_21YiL3N5r5f5-SPNrGVY" + assertEqual(config.ComputeCloak(v4ip), "4khy3usk8mfu42pe.oragono", t) + assertEqual(config.ComputeCloak(v6mappedIP), "4khy3usk8mfu42pe.oragono", t) + assertEqual(config.ComputeCloak(v6ip), "mxpk3c83vdxkek9j.oragono", t) + assertEqual(config.ComputeCloak(v6ipsamecidr), "mxpk3c83vdxkek9j.oragono", t) +} + +func TestCloakShortv4Cidr(t *testing.T) { + config := CloakConfig{ + Enabled: true, + Netname: "oragono", + Secret: "_BdVPWB5sray7McbFmeuJL996yaLgG4l9tEyficGXKg", + CidrLenIPv4: 24, + CidrLenIPv6: 64, + NumBits: 60, + } + config.postprocess() + + v4ip := easyParseIP("8.8.8.8") + assertEqual(config.ComputeCloak(v4ip), "3cay3zc72tnui.oragono", t) + v4ipsamecidr := easyParseIP("8.8.8.9") + assertEqual(config.ComputeCloak(v4ipsamecidr), "3cay3zc72tnui.oragono", t) +} + +func TestCloakZeroBits(t *testing.T) { + config := cloakConfForTesting() + config.NumBits = 0 + config.Netname = "example.com" + config.postprocess() + + v4ip := easyParseIP("8.8.8.8").To4() + assertEqual(config.ComputeCloak(v4ip), "example.com", t) +} + +func TestCloakDisabled(t *testing.T) { + config := cloakConfForTesting() + config.Enabled = false + v4ip := easyParseIP("8.8.8.8").To4() + assertEqual(config.ComputeCloak(v4ip), "", t) +} + +func BenchmarkCloaks(b *testing.B) { + config := cloakConfForTesting() + v6ip := easyParseIP("2001:0db8::1") + b.ResetTimer() + for i := 0; i < b.N; i++ { + config.ComputeCloak(v6ip) + } +} diff --git a/irc/config.go b/irc/config.go index 597b9309..639dc7a4 100644 --- a/irc/config.go +++ b/irc/config.go @@ -263,6 +263,38 @@ type TorListenersConfig struct { MaxConnectionsPerDuration int `yaml:"max-connections-per-duration"` } +type CloakConfig struct { + Enabled bool + Netname string + Secret string + CidrLenIPv4 int `yaml:"cidr-len-ipv4"` + CidrLenIPv6 int `yaml:"cidr-len-ipv6"` + NumBits int `yaml:"num-bits"` + + numBytes int + ipv4Mask net.IPMask + ipv6Mask net.IPMask +} + +func (cloakConfig *CloakConfig) postprocess() { + // sanity checks: + numBits := cloakConfig.NumBits + if 0 == numBits { + numBits = 80 + } else if 256 < numBits { + numBits = 256 + } + + // derived values: + cloakConfig.numBytes = numBits / 8 + // round up to the nearest byte + if numBits%8 != 0 { + cloakConfig.numBytes += 1 + } + cloakConfig.ipv4Mask = net.CIDRMask(cloakConfig.CidrLenIPv4, 32) + cloakConfig.ipv6Mask = net.CIDRMask(cloakConfig.CidrLenIPv6, 128) +} + // Config defines the overall configuration. type Config struct { Network struct { @@ -297,6 +329,7 @@ type Config struct { isupport isupport.List ConnectionLimiter connection_limits.LimiterConfig `yaml:"connection-limits"` ConnectionThrottler connection_limits.ThrottlerConfig `yaml:"connection-throttling"` + Cloaks CloakConfig `yaml:"ip-cloaking"` } Languages struct { @@ -728,6 +761,8 @@ func LoadConfig(filename string) (config *Config, err error) { config.History.ClientLength = 0 } + config.Server.Cloaks.postprocess() + for _, listenAddress := range config.Server.TorListeners.Listeners { found := false for _, configuredListener := range config.Server.Listen { diff --git a/irc/gateways.go b/irc/gateways.go index 495dfee0..95f05aee 100644 --- a/irc/gateways.go +++ b/irc/gateways.go @@ -70,6 +70,7 @@ func (client *Client) ApplyProxiedIP(session *Session, proxiedIP string, tls boo ipstring := parsedProxiedIP.String() client.server.logger.Info("localconnect-ip", "Accepted proxy IP for client", ipstring) rawHostname := utils.LookupHostname(ipstring) + cloakedHostname := client.server.Config().Server.Cloaks.ComputeCloak(parsedProxiedIP) client.stateMutex.Lock() defer client.stateMutex.Unlock() @@ -77,6 +78,7 @@ func (client *Client) ApplyProxiedIP(session *Session, proxiedIP string, tls boo client.proxiedIP = parsedProxiedIP session.rawHostname = rawHostname client.rawHostname = rawHostname + client.cloakedHostname = cloakedHostname // nickmask will be updated when the client completes registration // set tls info client.certfp = "" diff --git a/irc/server.go b/irc/server.go index 87ced2b0..ced8cb7c 100644 --- a/irc/server.go +++ b/irc/server.go @@ -21,6 +21,8 @@ import ( "time" "unsafe" + "golang.org/x/crypto/sha3" + "github.com/goshuirc/irc-go/ircfmt" "github.com/oragono/oragono/irc/caps" "github.com/oragono/oragono/irc/connection_limits" @@ -283,6 +285,32 @@ func (server *Server) checkTorLimits() (banned bool, message string) { } } +// simple cloaking algorithm: normalize the IP to its CIDR, +// then hash the resulting bytes with a secret key, +// then truncate to the desired length, b32encode, and append the fake TLD. +func (config *CloakConfig) ComputeCloak(ip net.IP) string { + if !config.Enabled { + return "" + } else if config.NumBits == 0 { + return config.Netname + } + var masked net.IP + v4ip := ip.To4() + if v4ip != nil { + masked = v4ip.Mask(config.ipv4Mask) + } else { + masked = ip.Mask(config.ipv6Mask) + } + // SHA3(K || M): + // https://crypto.stackexchange.com/questions/17735/is-hmac-needed-for-a-sha-3-based-mac + input := make([]byte, len(config.Secret)+len(masked)) + copy(input, config.Secret[:]) + copy(input[len(config.Secret):], masked) + digest := sha3.Sum512(input) + b32digest := utils.B32Encoder.EncodeToString(digest[:config.numBytes]) + return fmt.Sprintf("%s.%s", b32digest, config.Netname) +} + // // IRC protocol listeners // diff --git a/irc/utils/crypto.go b/irc/utils/crypto.go index 116f596a..8eb15440 100644 --- a/irc/utils/crypto.go +++ b/irc/utils/crypto.go @@ -11,7 +11,7 @@ import ( var ( // slingamn's own private b32 alphabet, removing 1, l, o, and 0 - b32encoder = base32.NewEncoding("abcdefghijkmnpqrstuvwxyz23456789").WithPadding(base32.NoPadding) + B32Encoder = base32.NewEncoding("abcdefghijkmnpqrstuvwxyz23456789").WithPadding(base32.NoPadding) ) const ( @@ -24,7 +24,7 @@ func GenerateSecretToken() string { var buf [16]byte rand.Read(buf[:]) // 26 ASCII characters, should be fine for most purposes - return b32encoder.EncodeToString(buf[:]) + return B32Encoder.EncodeToString(buf[:]) } // securely check if a supplied token matches a stored token diff --git a/oragono.yaml b/oragono.yaml index 0e5e68e5..9a244cb6 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -188,6 +188,45 @@ server: # - "192.168.1.1" # - "2001:0db8::/32" + # IP cloaking hides users' IP addresses from other users and from channel admins + # (but not from server admins), while still allowing channel admins to ban + # offending IP addresses or networks. In place of hostnames derived from reverse + # DNS, users see fake domain names like pwbs2ui4377257x8.oragono. These names are + # generated deterministically from the underlying IP address, but if the underlying + # IP is not already known, it is infeasible to recover it from the cloaked name. + ip-cloaking: + # whether to enable IP cloaking + enabled: false + + # fake TLD at the end of the hostname, e.g., pwbs2ui4377257x8.oragono + netname: "oragono" + + # secret key to prevent dictionary attacks against cloaked IPs + # any high-entropy secret is valid for this purpose: + # you MUST generate a new one for your installation. + # suggestion: use the output of this command: + # python3 -c "import secrets; print(secrets.token_urlsafe())" + # note that rotating this key will invalidate all existing ban masks. + secret: "siaELnk6Kaeo65K3RCrwJjlWaZ-Bt3WuZ2L8MXLbNb4" + + # the cloaked hostname is derived only from the CIDR (most significant bits + # of the IP address), up to a configurable number of bits. this is the + # granularity at which bans will take effect for ipv4 (a /32 is a fully + # specified IP address). note that changing this value will invalidate + # any stored bans. + cidr-len-ipv4: 32 + + # analogous value for ipv6 (an ipv6 /64 is the typical prefix assigned + # by an ISP to an individual customer for their LAN) + cidr-len-ipv6: 64 + + # number of bits of hash output to include in the cloaked hostname. + # more bits means less likelihood of distinct IPs colliding, + # at the cost of a longer cloaked hostname. if this value is set to 0, + # all users will receive simply `netname` as their cloaked hostname. + num-bits: 80 + + # account options accounts: # account registration