If you’re like most people, there’s a good chance that it’s been years since you’ve sent an email that wasn’t cryptographically signed. You don’t use PGP, you say? Well, even if you are not signing your email, your provider is almost certainly doing it for you. Plausible deniability has been tossed aside in the name of stopping spam, but it doesn’t have to be.
DKIM, originally standardized in 2007 by RFC 4871, now has near universal[1] adoption. To quote the RFC, the goal behind the protocol is to “permit a signing domain to assert responsibility for a message, thus protecting message signer identity and the integrity of the messages they convey”. It’s one of several technologies used prevent the sender identity information in email from being spoofed[2]. Anti-spam systems use it to help determine whether to consider the reputation of a domain name when making a processing decision.
While DKIM was designed to be useful for spam prevention, the cryptographic signatures it uses have quietly made a property called “non-repudiation” the new normal for email. The term is used in in contract law — for example if someone claims “that’s not my signature”, they could be said to be “repudiating” the authenticity of the document. In the case of email, the impact is that if you have a copy of an email in its original format including full headers (for example, an email spool dump) you can check the signature. The extent to which this is a reliable means of verification varies depending on the circumstances — keys short enough to be cracked used to be common, and in some cases straight-up theft of the private keys is plausible.
Meanwhile, secure messaging tools like OTR and its successors have taken the approach of explicitly providing “deniable encryption”. The state of the art allows a sender, given a recipient’s public key, to craft a fake transcript apparently between the two of them that will pass cryptographic checks. This is generally fine for users of these apps because they know what they said. To the best of my knowledge, there is nowhere this creates a legal “get out of jail free” card. All it really does is ensure the users of these tools aren’t reducing their deniability by using the tool. This is an issue where DKIM really fails its users, and I’m apparently not the only one that feels this way.
Apropos of nothing, I really wish Gmail would start publishing its expired DKIM secret keys.
A little over three years ago, I started doing exactly that for my domain. Since then, I’ve had a key rotation script running every day, generating a new key and adding the appropriate record (called a “selector”).
20170829- |
Each selector remains live for seven days, then is “revoked” by publishing an update blanking the public key portion of the record.
20170829- |
Once another three days pass, the minimal set of RSA parameters needed to recreate the public and private keys are published in the selector’s “notes” field.
20170829- |
The format here is non-standard, as a full RSA private key with all of the redundant data it includes would exceed the 255 character limit for strings stored in DNS[3]. A small Python script is enough to reconstitute everything, though.
1 | import gmpy2, sys, dns.resolver |
2 | from Cryptodome.PublicKey import RSA |
3 | from base64 import b64decode as b64d |
4 | |
5 | def decode_dkim_private(txt): |
6 | params = dict() |
7 | # Parse the DKIM selector record. |
8 | for key, _, val in map(lambda x: x.partition('='), txt.split(';')): |
9 | if key == 'n': |
10 | for k, v in map(lambda x: x.split(':'), val.split(',')): |
11 | params[k] = int.from_bytes(b64d(v), 'big') |
12 | # Compute rest of RSA keypair parameters (if possible). |
13 | if all (k in params for k in ('e', 'p', 'q')): |
14 | params['n'] = params['p'] * params['q'] |
15 | phi = (params['p'] - 1) * (params['q'] - 1) |
16 | params['d'] = int(gmpy2.invert(params['e'], phi)) |
17 | rsa = map(lambda x: params[x], 'nedpq') |
18 | return RSA.construct(tuple(rsa)) |
19 | else: |
20 | return None |
21 | |
22 | if __name__ == '__main__' and len(sys.argv) == 3: |
23 | domain = sys.argv[1] |
24 | selector = sys.argv[2] |
25 | for answer in dns.resolver.query(selector + '._domainkey.' + domain, 'TXT'): |
26 | txt = str(answer).strip('"') |
27 | print(decode_dkim_private(txt).exportKey().decode()) |
An example run:
$ ./dkim-private.py 'ryanc.org' '20170829-b29b2444f764c222c3faf5c' |
-----BEGIN RSA PRIVATE KEY----- |
MIICXQIBAAKBgQDkOSIRW7R8a3e0J0lZqbBJSpHJYPk043/OB3lcT2apKtnu7MLj |
IRqUAgRyYSVAGC10ID2Qlxmy1Ji3EBRB1qI2IsNKgC2C4qzGxx54ShpVR/8yY9Qy |
1eyNtTF5Y/XSoLWoRVO1oly+WL+4O2TRuyujEwoZcFUwXzuuuqJtzbI17wIDAQAB |
AoGBAKClArD7PzExKGJcIQqHIjqEzdfVdbVfyc+JfUiX72h2bE78wzXDUIUMYnrs |
nJ7gJeaO5ycG5ST29sQtAkVRwn1KTLaU9fYmGpbkKyOWWfmztppZIvwi9l4tU5h2 |
GJVw+HbhcWO6tYbTqR9Bc8IelXyVibwmJwImr0AoD8sBLryhAkEA6o/8upWykC5U |
Sot9Q2o5M89EO1qA7J/ao/FPc2TUJKat+z4JXde2HWW/8D3LJR4hGwSpgwLMq9dr |
TzdjbzFTkQJBAPkU07sfsjMdCz8lw5AEIhAXDrfMWK6+tLNbFzv+Z0EkEmQZS7US |
Sh0Kc+uTJyMZTggftbpqi1vKu/IRwtwLMX8CQFT/ABGMlTvxzdGFYkq/fyLrBEqN |
rRIRiuTFWIj0DHuLepgEDtjWhcN5T2f6vFYi6NQliFdU+F18ngICjCGKukECQHse |
ClIyJpkRQB/kgLfM8zFU1FeRUDx/0z3cRq3G4C7Yr6Z+wmcsNSoJoqbMw8mblnB5 |
jBAq3dtvaFsM4G53se0CQQC9ocR9eQdXvq5ibwZAmgYcMLEaq7NeX//l6zdxLd52 |
NcVcuaAUzf5KdTRwA9gJ4Qdzwntc+UB2ElpI2AOj7AFV |
-----END RSA PRIVATE KEY----- |
When I originally set this up, I was a bit concerned that I’d run into issues with filtering systems trying to validate my sent emails significantly after delivery. Per the RFC:
A signer should not sign with a private key when the selector containing the corresponding public key is expected to be revoked or removed before the verifier has an opportunity to validate the signature. The signer should anticipate that verifiers may choose to defer validation, perhaps until the message is actually read by the final recipient. In particular, when rotating to a new key pair, signing should immediately commence with the new private key and the old public key should be retained for a reasonable validation interval before being removed from the key server.
In the process of writing this up, I went through the 24 months of query logs I have. With very few exceptions (most of which were probably my own testing) there were no lookups against selectors other than on the day they were being used, so this doesn’t seem to be a problem in practice.
I alluded to it earlier, but I want to be clear — publishing DKIM private keys like this mainly addresses leaks as a threat model. In a legal dispute, mail server logs and/or stored mail can be subpoenaed if the authenticity of messages is in question. Even in my case, where I have my own mail server on dedicated hardware with full disk encryption at an undisclosed location, most mail I send will be delivered to a server operated by a third party with no incentive to alter logs at the behest of the recipient.
It would make for a fascinating experiment for one of the privacy focused email providers to try deploying a key management strategy similar to the one I’ve described in this post.