Signed Email Addresses

Posted

Last updated

I recently made the questionable decision of running my own mailserver. For now, I am outsourcing sending reputation as there are many easy ways to do that for near-zero cost, but I am in charge of the inbound.

This all started when Google decided that legacy free “Google Apps for Your Domain” was sucking customers away from their paid offering. I knew that this was coming and was mostly ready for it (other than Google Photos) so I started trying a variety of mail providers, but none fit the bill. It turns out that GMail is actually pretty good inbox provider. The biggest problem area I had with other providers was spam filtering. FastMail was pretty good but blocked some important messages. MXroute blocked both too much and too little. I almost went back to Google (the backlash convinced them to roll back their decision for “personal use”). However, in the end I decided that running my own mailserver can’t be that bad.

More on that setup in a future post but I want to talk about an interesting spam control technique that I have been envisioning for years. While simple, it isn’t quite simple enough to implement with the filtering capabilities that most providers offer. But with access to custom Rspamd rules it was a snap to implement.

This is a form of task-specific email addresses. For example GMail allows sub-addressing with +. So me+github@example could be given to GitHub then I can easily organize my GitHub messages. If I do this for every account I create then if I start receiving spam I know who sold my info.

The main problem with this is that sub-addressing with + is incredibly common. So a lot of tools and services will strip it. This means that all but the least sophisticated spammer will be able to “guess” your main email, or just randomize the stuff after the + to bypass any block.

A common solution to this is full-blown email aliases. This way you have z2y8r3w3n@example and any unknown address isn’t allowed. Now spammers can’t guess random addresses or strip the per-account info. There are many options for this such as a third-party service like Firefox Relay and built-in features from email providers such as FastMail’s Masked Email. These services are great, but the main problem is that you need to go and configure a new address every time you sign up for a new service. This usually isn’t too painful but it still requires a bunch of clicks.

My solution was signed addresses. This way generating email addresses is easy. It can be done with a browser extension or website without talking to any service or even having your device online. As I was thinking about this I saw blame.email on Hacker News. So I decided to steal their algorithm so that I can take advantage of their frontend.

Their algorithm is pretty simple. It uses MD5 which is unfortunate (and means that you can’t use the browser crypto.subtle.digest API) but the cryptography isn’t important here. The goal is to block naive automation, not motivated attackers. A CRC-32 would have been sufficient.

name = "github.com"
salt = "Sup3r S3cre+"
hash = md5("$name+$salt").hex[0:8]
signed_address = "$name-$hash@example"

So I get out github.com-3ece8a38@example.

Then I configure Rspamd to validate these signatures.

local rspamd_cryptobox_hash = require "rspamd_cryptobox_hash"
local md5 = rspamd_cryptobox_hash.create_specific "md5"

local known = {
	abuse = true,
	blog = true,
	spam = false,
	-- Others redacted ...

	-- Signed addresses that have been revoked.
	["spammer-a8bffde3"] = false,
};


local kevincox_to_id = rspamd_config:register_symbol{
	type = "prefilter",
	name = "KEVINCOX_TO",
	score = 0,
	flags = "empty nice trivial",
	description = "Valid to address",
	callback = function(task)
		if task:get_user() then
			return -- Assume that authenticated mail is outbound and don't check signatures.
		end

		local unknwon = nil
		local known = nil
		local invalid = nil
		for _, addr in ipairs(task:get_recipients("any")) do
			local lower_user = rspamd_util.lower_utf8(rspamd_util.normalize_utf8(addr.user))
			local k = KNOWN[lower_user]
			if k == true then
				known = addr.user
			elseif k == false then
				task:set_pre_result{
					action = "reject",
					message = "Known spammer",
					module = "KEVINCOX_TO",
				}
				return
			elseif lower_user:find "-" then
				local name, hash = lower_user:match "^(.*)-([^-]*)$"
				md5:reset()
				md5:update(name .. "+" .. "Sup3r S3cre+")
				local expected = md5:hex():sub(0, 8)

				if hash == expected then
					task:insert_result(true, "KEVINCOX_TO_SIGNED", 1, name)
					task:set_pre_result{
						action = "accept",
						message = "Signed address",
						module = "KEVINCOX_TO",
					}
					return
				end

				invalid = addr.user
			else
				unknwon = addr.user
			end
		end

		if known then
			task:insert_result(true, "KEVINCOX_TO_KNOWN", 1, known)
		elseif invalid then
			task:insert_result(true, "KEVINCOX_TO_SIGNED_INVALID", 1, invalid)
		elseif unknown then
			task:insert_result(true, "KEVINCOX_TO_UNKNOWN", 1, unknown)
		else
			task:insert_result(true, "KEVINCOX_TO_MISSING", 1)
		end
	end,
}
rspamd_config:register_symbol{
	name = "KEVINCOX_TO_SIGNED",
	type = "virtual",
	parent = kevincox_to_id,
	score = -10,
}
rspamd_config:register_symbol{
	name = "KEVINCOX_TO_MISSING",
	type = "virtual",
	parent = kevincox_to_id,
	score = 2,
}
rspamd_config:register_symbol{
	name = "KEVINCOX_TO_KNOWN",
	type = "virtual",
	parent = kevincox_to_id,
	score = 0,
}
rspamd_config:register_symbol{
	name = "KEVINCOX_TO_SIGNED_INVALID",
	type = "virtual",
	parent = kevincox_to_id,
	score = 10,
}
rspamd_config:register_symbol{
	name = "KEVINCOX_TO_UNKNOWN",
	type = "virtual",
	parent = kevincox_to_id,
	score = 5,
}
rspamd_config:register_dependency("KEVINCOX_TO", "DMARC_POLICY_REJECT")
rspamd_config:register_dependency("KEVINCOX_TO", "DMARC_POLICY_QUARANTINE")

Ideally you would be able to reject everything that is unsigned, however the world isn’t perfect.

  1. I want some long-lived email addresses to give to friends.
  2. This is an old domain, so I have given out various addresses in the past that I am not ready to revoke yet.

As time goes on, and I collect more data I will slowly tighten the rules. For now everything gets slotted into one of the following categories.

  1. Known addresses: Allow, subject to regular spam checking.
  2. Blocked addresses: Reject and report.
  3. Valid signed addresses: Allow, skipping the spam filter.
  4. Blocked patterns: Reject. (For example I reject all addresses that contain a - or . because no emails I have given out in the past used these characters. Any instances of these are guessed addresses.)
  5. Others: Allow (for now), subject to increased spam checking.

My setup uses a catchall domain, but it could trivially be modified to work with sub-addressing. Just use format such as me+$name-$hash@example. Of course, it will be extra valuable to reject mail sent to just me@example as stripping the detail will be common.

Conclusion

Is this solution over-designed? Almost certainly. You can get most of the benefit without the signing. Just give out emails like github@example and 99% of companies won’t try to guess other email addresses. If an address is sold, blocking that address will likely be enough. This gives you knowledge of who shared your email and decent blocking for little effort.

However, the fact that this system is reliable enough to skip the spam filter is also very beneficial. I no longer have to worry about an important message getting lost in the Junk folder as these signed addresses ensure that I get everything from these privileged senders (until I revoke their privileges).

Another minor benefit of this system is that it reduces load on the spam filter. I am trying to get away with a very cheap server and 1GiB of RAM is tight for what I am running on it. Spam filtering gets slowed down by slow network requests and cold disk caches and was frequently taking 20-60s. Signed addresses skip 99% of the spam filter and consistently take under a second. This means that messages appear in my inbox noticeably faster which is a nice improvement.