The subject of Signing and Verifying messages in the frame of the CSF is based on the concept of a joined pair of keys, called the public and private key. The private key is kept secret while the public key being available to all. Both keys can encrypt data, but only the other key can decrypt that data.
This means that if you have some data that has been encrypted by a 3rd party, and you have their private key, and it decrypts correctly, then you know that the data definitely came from that 3rd party, as only they could have encrypted it. These concepts form the basics of Signing and Verifying. Further information can be acquired from https://en.wikipedia.org/wiki/Public-key_cryptography and https://en.wikipedia.org/wiki/Digital_signature
DKIM is an RFC Standard https://datatracker.ietf.org/doc/html/rfc6376 and sets out a specific method for using public/private keys to sign and verify messages. Effectively the sender creates a hash of the message using the rules set out in the RFC and then encrupts it using their private key. When the recipient receives the message with the signature, they follow the same method to create a hash of the message and then decrypt the signature to get the original hash. If those both values match then the recipient knows that the parts of the message used for the hash have not been altered and (2) that if definitely came from the sender.
From the RFC https://datatracker.ietf.org/doc/html/rfc6376#section-3.7 we have copied the following section and simplified it slightly
body-hash = hash-alg (canon-body)
data-hash = hash-alg (h-headers, D-SIG, body-hash)
signature = sig-alg (d-domain, selector, data-hash)
where:
body-hash: is the output from hashing the body, using hash-alg.
hash-alg: is the hashing algorithm specified in the "a" parameter.
canon-body: is a canonicalized representation of the body, produced
using the body algorithm specified in the "c" parameter,
as defined in Section 3.4 and excluding the
DKIM-Signature field.
data-hash: is the output from using the hash-alg algorithm, to hash
the header including the DKIM-Signature header, and the
body hash.
h-headers: is the list of headers to be signed, as specified in the
"h" parameter.
D-SIG: is the canonicalized DKIM-Signature field itself without
the signature value portion of the parameter, that is, an
empty parameter value.
signature: is the signature value produced by the signing algorithm.
sig-alg: is the signature algorithm specified by the "a"
parameter.
d-domain: is the domain name specified in the "d" parameter.
selector: is the selector value specified in the "s" parameter.
NOTE: Many digital signature APIs provide both hashing and
application of the RSA private key using a single "sign()"
primitive. When using such an API, the last two steps in the
algorithm would probably be combined into a single call that would
perform both the "a-hash-alg" and the "sig-alg".
You will note that the l-param
from the RFC has been removed, as, although it may be useful on emails that are many Mb in size, it is not relevant for messages that at most will be a few hundred Kb.
There are currently 2 HTTP Headers used, they are :
HTTP Header | Use |
---|---|
X-CSF-SIGNATURE-DATESTAMP | Stores the date+time the signature was created. Uses the format yyyyMMddHHmmssS where S is a tenth of a second unit. We shall use 202508121340391 as the example |
X-CSF-SIGNATURE | This stores the signature of the message being sent. |
A sample X-CSF-SIGNATURE looks like this
a=rsa-sha256; c=simple/simple; s=809b6e65-a6e7-40f6-8b52-04dd65b6fce1; d=gplb-test.nowyoyo.net; bh=/CWGLVBT2gdyO5cudfAEUSF43KHLmlJjU/Nr2YNqkos=; h=X-CSF-SIGNATURE-DATESTAMP; b=4J4OGZfxiIetZP8WMMt56+MBqX0W/KdV9pAxzeS4PBC5rjmRtQc9PNMfkd0+9S3bOtr6k5HFzE7E8Q+RrCDIKgm4MNZJmCHUzpFu6NqzV5/zdhM0sR7YYP0QHGjYqgvF4b9+pgT58s2AehFRDXAqdnExJx5K1Hfj9896tj9AgEqUNbxa6VAdrAR4FY2zRpCnOQo7/XDt0DkCTPUrIiP3B1Mrj4WUeYOa2c2xbTK3w6IHmQ22+INEcbL8msW5iXg/jtY/YI/NQWgouXBJoGi99dZIi8VqKVd/YxXFElGRaAUJiP1vpPX1dlW5n4mUdS1o63rie28OHWSAgMfg7QPwlsM8f0W2yVAKidrRoO98IXxBwLBUBM54GN8cQRzSYURO5Mv3cv8a9Dt1GgiSRXc9iG8kYL2t//dX1+/lb0iS17rQPDr+iW5625fNcECkLu7YMANtmCCEF54eKlRRit7bhgy3hnWmjE9kV9kzpj/ZB3TMzE8CEEIiUsQ62ZjlfnJi;
It can clearly be seen that this is identical to the DKIM header that email uses. All the tags seen above are the same as the ones used in the RFC https://datatracker.ietf.org/doc/html/rfc6376#section-3.5 but here is a quick summary
Tag | |
---|---|
a |
The algorithms used to create the hash and encryption |
s |
The selector part of the domain, will always be the CPs ID |
c |
The header/body Canonicalization Algorithms to be used. Always simple/simple for CSF HTTP Messages |
d |
The hostname to use in conjunction with the selector to get the Public Key via DNS |
h |
Header fields that should be used in creating the signature |
bh |
The Base64 hash of the raw body using the algorithm specfified in a |
b |
The Base64 signature data |
At the present time DKIM only requires implementors to support 2 encryption algorithms and 1 hashing function. In the example above rsa-sha256
is used and means RSA is the encryption and signing algorithm and SHA256 is the has function. It has also become standard to also support SHA512 although this is not mentioned in the RFC.
SHA1 was used in the early days of DKIM but has since been removed, as it is no longer secure.
The other encryption and Signing algorithm introduced in 2018 is Ed25519
in RFC https://datatracker.ietf.org/doc/html/rfc8463, now although this is newer it has 1 major benefit in that the public keys are a lot shorter.
k=rsa; p=MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA6/RekuGAsET2Ir8E4K5BU74MBXebo7QwrOnSGirtNgRLDcwP2R84Eeum9BLxZtiumUrmO6ja7DrteSZhzOyC09KM3b22zV43bm7nv/FF40+KpTDQ/FZ32W838UIZGgHOyICk37hlPvnGrVPg7rT6HAKsaj5W0zbvO5vKDUOz2r3bvrWo79YSxtVfGQ1pHhhZYV2dL1C0uiADbz5L1Cu0qCBjqGJQbYq4ALdaKTnAm2NG/N9W2i3IWJUQIyiTr11qtBrrR8sj2YhYeph6GTsOXggpAWZnEHZz+LKzD4roVqUxkByLWRLB2I5Ju/xPPtTjCDakfvGlsiWl5BMAhn79p0mW6hzcA4DT7Iz+nSV8K3XMU4rxs0AcaafcBsKREmfbGz4K3ycWHZ/Yrxb+PqYHy7NSK76mHsAjuU8nuKYtZGIYHwT4friS2R3HtXJ+olLmdZ+XnkpdvEGDb+ZVv/G9vYp020LZQBBd0hp1Ez8a/3F7jRt8xUJ0ljstWjbq+m3bAgMBAAE=
k=ed25519; p=MCowBQYDK2VwAyEAqiCrokPReIFI1h4Jdp7RCRRVQ4Fltdyn429O4H2jYog=
So here is a list of the algorithms we should support as part of CSF
Encryption & Signing | Hashing | a value |
---|---|---|
RSA | SHA-256 | rsa-sha256 |
RSA | SHA-512 | rsa-sha512 |
Ed25519 | SHA-256 | ed25519-sha256 |
Ed25519 | SHA-512 | ed25519-sha512 |
This is a short list so shouldn't cause implementers any problems.
Due to Email servers potentially modifying the body and headers it was necessary to specify how the headers and body were used in the overall signature. For CSF HTTP Messages using simple/simple will provide the most straight-forward implementation, as it does not require any post-processing on the headers or body
The DKIM standard has a defined format for both the URL where the public key is stored and how it is stored.
The domain is formated as [s]._domainkey.[d]
, the s
& d
are the values in the X-CSF-SIGNATURE and the s
is the CP ID of the source of the message.
which from the signature above is 809b6e65-a6e7-40f6-8b52-04dd65b6fce1._domainkey.gplb-test.nowyoyo.net
The CP ID will also match an entry in the directory, which will define the valid d
that the public key is held for the CP. Any request where the d
does not match the directory entry must be rejected.
The public key of the CP is encoded as Base64 in a TXT record. https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.1
k=rsa; p=MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA6/RekuGAsET2Ir8E4K5BU74MBXebo7QwrOnSGirtNgRLDcwP2R84Eeum9BLxZtiumUrmO6ja7DrteSZhzOyC09KM3b22zV43bm7nv/FF40+KpTDQ/FZ32W838UIZGgHOyICk37hlPvnGrVPg7rT6HAKsaj5W0zbvO5vKDUOz2r3bvrWo79YSxtVfGQ1pHhhZYV2dL1C0uiADbz5L1Cu0qCBjqGJQbYq4ALdaKTnAm2NG/N9W2i3IWJUQIyiTr11qtBrrR8sj2YhYeph6GTsOXggpAWZnEHZz+LKzD4roVqUxkByLWRLB2I5Ju/xPPtTjCDakfvGlsiWl5BMAhn79p0mW6hzcA4DT7Iz+nSV8K3XMU4rxs0AcaafcBsKREmfbGz4K3ycWHZ/Yrxb+PqYHy7NSK76mHsAjuU8nuKYtZGIYHwT4friS2R3HtXJ+olLmdZ+XnkpdvEGDb+ZVv/G9vYp020LZQBBd0hp1Ez8a/3F7jRt8xUJ0ljstWjbq+m3bAgMBAAE=
Where k
is the key type, and p
is the public key in base64.
What follows are simplistic Low-Level worked examples of the DKIM process for signing and verifying. However, it is possible that developers are able to make use of Higher-Level libraries that already implement the DKIM RFC and remove a lot of the low level implementation.
i.e. https://github.com/apache/james-jdkim is a Java library that can be used with some minor modifications, thereby removing the need to implement your own. https://github.com/jstedfast/MailKit is .NET library doing the same.
public String createSignature() throws Exception {
byte[] httpBody = """
{
"test_field": "Test Data"
}""".getBytes();
String privateKeyPEM = "MIIG/gIBADANBgkqhkiG9w0BAQEFAASCBugwggbkAgEAAoIBgQDr9F6S4YCwRPYivwTgrkFTvgwFd5ujtDCs6dIaKu02BEsNzA/ZHzgR66b0EvFm2K6ZSuY7qNrsOu15JmHM7ILT0ozdvbbNXjdubue/8UXjT4qlMND8VnfZbzfxQhkaAc7IgKTfuGU++catU+DutPocAqxqPlbTNu87m8oNQ7Pavdu+tajv1hLG1V8ZDWkeGFlhXZ0vULS6IANvPkvUK7SoIGOoYlBtirgAt1opOcCbY0b831baLchYlRAjKJOvXWq0GutHyyPZiFh6mHoZOw5eCCkBZmcQdnP4srMPiuhWpTGQHItZEsHYjkm7/E8+1OMINqR+8aWyJaXkEwCGfv2nSZbqHNwDgNPsjP6dJXwrdcxTivGzQBxpp9wGwpESZ9sbPgrfJxYdn9ivFv4+pgfLs1IrvqYewCO5Tye4pi1kYhgfBPh+uJLZHce1cn6iUuZ1n5eeSl28QYNv5lW/8b29inTbQtlAEF3SGnUTPxr/cXuNG3zFQnSWOy1aNur6bdsCAwEAAQKCAYANK3PJUfZpDfqbSXIACUVJdcomsfgAAFfpK2HYFH0lZXk7z/tXU2yvP2C4il79G+soB1nVr1exXnaimPMxNtliOt99yljr42UjFJroGlxSwL3e+TefyowE9Fq5EOub5Mk490kzazRMDJ7N4EQBPtOyE0D7ofb5qOGlCU2lRXV5Fd1Uzy1RgsrWN9K+Xd4KWVbbObznmWMXRLIFhd4dNHneGnSzPxavsxrsNr1hVmXNXVuLVB+h0XMS5PHkbHre5vOWwk0x/BpqHtA15sU6tdtWAem1zR8cKDn8D4uVSIU91K6sAqSR34YpJAb79GiEtTFdXCA1IPMyBRHmwF2jiK0UZpbBhuYnLkwcbSMul7xaMlEHMTxZxgMFK765IKtK/cQjENMCcFO9JSRkTMRsnD8pjNU846keyMFUZ1ap2FddUpfNuAV2f9844QNJyF07oly4gmx/+ruzjWjWN+ByUJvB7zEZh/zHcUCgd9+Ixay1F/AD6Dub3mIh/toi2VqOFnkCgcEA7TM/oQu8udZc3M9IWdipJV2iehhPqXAIUqipQnICK780qYRW6UdVSXk1twEAg+VymITOht1ICCzyqKP9KhNLRKfAnZLJkV1zEEJin19gp6YNoPWfaJEwUrpb6WjgvADdPuq+teZ+Hk5G8MoOKkqDt6rlrpdtib1mHTogyKKfjClX+cmwU2AkkcYWm/T/7tO3+aPWWFN+B70PUEf/K5OEfKIPiSGGPg04DWki99M+d+Gnt9OzrbDxC985MkZT43wZAoHBAP6n2PELun2BD2rEqUDV5HBDAewKSYWsyOsfTbvLMa1ALu54h/FQsrPKkgnkpPBGKRaDtgSixcigVvHKb6jm96Zn43pg7uxCq9QpKfg9pUj3orfNvw7GmgEg/J8vW8T6ux3jlhOsY12XegmabOvYHr0/M6zvX1KWtLXig2SZN53eAFCYMbkBrcwnDjgIcai48wasapa0p8mD3xoBOG14+lL2Y/DFHgoF+2l3njISZGBntBc/tgyOa104TqMy8+D4EwKBwQDHpk02RmTRnsaG7MmfJigo1Uk+r1vN6Ah5WpEs5j1BiSzQSh3FOE9nCmjV4jgGzIfKLG6RQYuxpfORUoZydc7yuKf9eWHDwv5ofxf3wRXfxnrOMi+8mggsecOHEMmoNKoEnR1sidc5tvUrE0cc/Z8kZunwLHD8cLiUfSq+9XKJTPtJuiN56gCd2jeJiYwp/3Zo3yg5K/12kgFjt1Xl3cK0DMw6xkbxz7qQPyA5rEp2KS88ISqpVbduILNJx7wwS3ECgcEAzQkZ3CLEWc6zOhTz7bcKAfWBs6oovk97SgxfSyf0bHk0EF/NnNeLusUMRpjo0Gi9JlqQEDV6p+mpd2617rlghoQ5HMy1MlcQAHfQSgZgcVqpkfI/tcbkMqp7nDPGYNg8Fnmq2VZAfxe6c8b5kf7l6RvdII1vI5EiGRwzDKlspVgcysdvqXUXmTuM8EKkOOQJEMN74rG8Mr1RwZ9f7oysiGXH3BDp+coNPkLIhapXVWPKFbn/eyakfV8bub0JrYYvAoHAc0forosmO0gtkBXOo4XJlEd4zZ7IoqEHoVarIodtIlSBDtEXibvKvuEoSlSosvskaUcc2nk3ZJPx2VkRkPt0lad9IY0bQde7mcpiYR4itCaj4Siz0JyKryPBL2lC/PlgkSpLCK92C8MEFrZOhXYnvrng2pP4EWI2s6xIrxi62s2OMyAkXoLcOI4pZKztDRwVs1M+vHuaNQtaRPcA8cf0MUQ5KbcXUixwhbfggN7hASQGYz1+AYaowtDBZATPS6QQ";
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyPEM)));
MessageDigest digest = MessageDigest.getInstance("SHA256");
// Get the body hash, using simple canonicalization (2nd part of 'c') as per https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.3
String bh = Base64.getEncoder().encodeToString(digest.digest(httpBody));
// bh = IVIj2cQQOAapFmSJl6X0y6dQgKWhYHqQetWe9mWINNQ=
// Assumes we are using a CP with ID 809b6e65-a6e7-40f6-8b52-04dd65b6fce1 that is hosted on the MAP Nowyoyo.
String sigTemplate = "a=rsa-sha256; q=dns/txt; c=simple/simple; s=809b6e65-a6e7-40f6-8b52-04dd65b6fce1; d=gplb-test.nowyoyo.net; v=1; h=X-CSF-SIGNATURE-DATESTAMP; bh=" + bh + ";";
// sigTemplate = a=rsa-sha256; q=dns/txt; c=simple/simple; s=809b6e65-a6e7-40f6-8b52-04dd65b6fce1; d=gplb-test.nowyoyo.net; v=1; h=X-CSF-SIGNATURE-DATESTAMP; bh=IVIj2cQQOAapFmSJl6X0y6dQgKWhYHqQetWe9mWINNQ=;
// Normally we would parse the 'a' tag to get the correct algorithm, but this is a simple example.
Signature signature = Signature.getInstance("SHA256withRSA");
// Initialise with the Private Key
signature.initSign(privateKey);
// As we are using simple canonicalization (1st part of 'c'), we add the header value only
signature.update("202412121340391".getBytes());
// Each Header must be appended with \r\n as per https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
signature.update("\r\n".getBytes());
// Append the signature using simple canonicalization (1st part of 'c') as per https://datatracker.ietf.org/doc/html/rfc6376#section-5.6
signature.update(("DKIM-Signature:" + sigTemplate+ " b=").getBytes());
// append the signature to the sigTemplate
String finalSignature = sigTemplate + " b=" + Base64.getEncoder().encodeToString(signature.sign());
// finalSignature = a=rsa-sha256; q=dns/txt; c=simple/simple; s=809b6e65-a6e7-40f6-8b52-04dd65b6fce1; d=gplb-test.nowyoyo.net; v=1; h=X-CSF-SIGNATURE-DATESTAMP; bh=IVIj2cQQOAapFmSJl6X0y6dQgKWhYHqQetWe9mWINNQ=; b=rSnlux6S7feBZBEA3wl0X+XA6RfNq8MFnS2nMo0dgEMNmQguNhRIpzuPBTU/BgPmZA57IXF4eMN886rFC5Lo0yy5cuSZpHaVbI5gvf/1hX/SAMJfa2PAipn1/9+yeUj8ajnUYMsO6v1i6bx6LA5Gq+mJEBm4XRwA3Ps3wdgJ1bzTQ7s1L8+/iXci1OqieMdWHvdBi7uncBhpFUQdHVEYZ7zpzDjG59B2i1Z2BGFBEfcTF9FJPfJbJoabUuzT1iZlcW1s18tnZyav/4X3ZTM5YIVGYo355gtiT4YNEmz+mwFN/cOvRkE+X7ukFvNrXOfjNTtfBf2aGWOxMuSFX9UkHi74Vyi5/Snim9Qp1OrKCYRhb+6NaqXev4rbs244t7NTAy0D3WcWxWZN5tf6R8rQ1jm1LklaX2xtr7GaM07mPGRFvGGukT7VSzo1nxD9JDKTiP/C8cA5+kunIFXNrst6xXI+0GYC3QiPZymsbsYEcp06AyOH/QZjUoGO77lAbZKJ
// The result is then added to the HTTP Request as Header X-CSF-SIGNATURE
return finalSignature;
}
In this code section, it is assumed that the Public Key has already been acquired from the DNS entry in the signature and has been verified that it matches the details from the directory
public boolean verifySignature(PublicKey publicKey, HttpHeaders httpHeaders, byte[] httpBody) throws NoSuchAlgorithmException, InvalidKeyException, SignatureException {
// Get Signature and Timestamp from header
String signatureHeader = httpHeaders.getFirst("X-CSF-SIGNATURE");
// pull out the headers stated in the 'h' tag, they are appended to the signature in the order they appear in 'h'
// in this simplistic example we already know that there was only 1 field mentioned.
String timestampHeader = httpHeaders.getFirst("X-CSF-SIGNATURE-DATESTAMP");
// timestampHeader = 202412121340391
// get the Base 64 decoded value of the 'b' tag
byte[] signatureBytes = getSignatureBytes(signatureHeader);
// get the signature line without the 'b' tag
String signatureTemplate = unsignedSignature(signatureHeader);
// signatureTemplate = a=rsa-sha256; q=dns/txt; c=simple/simple; s=809b6e65-a6e7-40f6-8b52-04dd65b6fce1; d=gplb-test.nowyoyo.net; v=1; h=X-CSF-SIGNATURE-DATESTAMP; bh=IVIj2cQQOAapFmSJl6X0y6dQgKWhYHqQetWe9mWINNQ=;
MessageDigest digest = MessageDigest.getInstance("SHA256");
// Get the body hash, using simple canonicalization (2nd part of 'c') as per https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.3
String bh = Base64.getEncoder().encodeToString(digest.digest(httpBody));
// bh = IVIj2cQQOAapFmSJl6X0y6dQgKWhYHqQetWe9mWINNQ=
// Check the bh is the same as the bh value in the signature. throw exception if not
verifyBodyHash(signatureHeader, bh);
// Normally we would parse the 'a' tag to get the correct algorithm, but this is a simple example.
Signature signature = Signature.getInstance("SHA256withRSA");
// Initialise with the public Key for Verification
signature.initVerify(publicKey);
// As we are using simple canonicalization (1st part of 'c'), we add the header value only
signature.update(timestampHeader.getBytes());
// Each Header must be appended with \r\n as per https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
signature.update("\r\n".getBytes());
// Append the signature using simple canonicalization (1st part of 'c') as per https://datatracker.ietf.org/doc/html/rfc6376#section-5.6
signature.update(("DKIM-Signature:" + signatureTemplate + " b=").getBytes());
// simple true/ false result
return signature.verify(signatureBytes);
}
If the message fails signature check there are 2 possible error codes to use
Error Code | Meaning |
---|---|
8101 | PERM_FAIL - The message failed verification. Retrying will not resolve anything |
8102 | TEMP_FAIL - The message failed verification due to a temporary problem such as a DNS fail. |
An 8102 error code is suggesting that a retry could resolve the message. It does not specify when the retry should take place.
// Example of PERM_FAIL where the source has attempted to sign from a domain that is not mentioned in the directory for that CP.
{
"errorText": "Domain gplb-test.nowyoyo.net is not valid key source for CP 809b6e65-a6e7-40f6-8b52-04dd65b6fce1",
"errorCode": 8101
}
// Example of PERM_FAIL where the body hash does not match. There is no point testing the signature further
{
"errorText": "Body Hash 3hzlA9zA8SAVapmd5ZZnMdwaZpk/WOyQXixrqVu/WMc= is different to signature /CWGLVBT2gdyO5cudfAEUSF43KHLmlJjU/Nr2YNqkos=",
"errorCode": 8101
}
// Example of PERM_FAIL where the Signature header X-CSF-SIGNATURE does not contain the mandatory tags
{
"errorText": "Signature has missing mandatory tag(s): [b, bh]",
"errorCode": 8101
}
// Example of TEMP_FAIL where the URL calculated from the X-CSF-SIGNATURE does not have a TXT value.
{
"errorText": "Unable to resolve 809b6e65-a6e7-40f6-8b52-04dd65b6fce1._domainkey.gplb-test.nowyoyo.net. TXT entry does not exist.",
"errorCode": 8102
}
// Example of TEMP_FAIL where the URL calculated from the X-CSF-SIGNATURE contains a TXT value, but unable to convert it into a public key.
{
"errorText": "Unable to resolve 809b6e65-a6e7-40f6-8b52-04dd65b6fce1._domainkey.gplb-test.nowyoyo.net. Cannot get public key",
"errorCode": 8102
}