#3. PKI and DKIM Message Signing
Connected Services Framework (CSF) — Part 2: Implementation — Version 2.0
#3.1 Overview
Without a centralised authentication body, the recipient of a message must verify that:
- The originator is who they claim to be
- The MAP is authorised to act on behalf of the CP
- The message has not been altered in transit
The CSF achieves this using DKIM DomainKeys Identified Mail, RFC 6376 — a well-established PKI-based signing standard originally designed for email, adapted here for HTTP message signing between MAPs.
DKIM is ideally suited to a federated network where trust has been established through onboarding, but ongoing verification of the CP/MAP relationship and payload integrity is required.
#3.2 Public/Private Key Pairs
The CSF uses asymmetric cryptography. Each CP has a key pair:
- Private key: Held securely by the MAP. Used to sign outbound messages on behalf of the CP.
- Public key: Published in the CP's DNS TXT records. Used by receiving MAPs to verify signatures.
If the signature encrypted with a private key can be correctly decrypted with the corresponding public key, the recipient knows the signed payload originated from the holder of that private key.
#3.3 DNS Records
Every CP registered with a MAP maintains two DNS TXT records:
#Domain Key Record
[RCPID]._domainkey.[cp-domain]
Contains the public key in RFC 6376 format. Example:
7d87c2a7-4f64-433c-8ae1-9460f19a0a13._domainkey.supercompany.com
TXT value: k=rsa; p=MIIBojANBgkqhkiG9w0BAQE... (Base64-encoded public key)
#MAP Key Record
[RCPID]._mapkey.[cp-domain]
Contains the URL of the MAP's message endpoint. Example:
7d87c2a7-4f64-433c-8ae1-9460f19a0a13._mapkey.supercompany.com
TXT value: https://switching.supermap.com/api/
#Why Two Records?
- The
_domainkeyrecord enables signature verification by any receiving MAP - The
_mapkeyrecord provides routing verification — the MAP name from the directory could be spoofed, but the DNS record cannot - Having the RCPID in the domain allows a CP to have multiple RCPIDs hosted at different MAPs if required
#CP Responsibilities
The CP only needs to create/update these two DNS records. All remaining processing is performed by the MAPs. During onboarding, the MAP (or CP) generates the key pair, places the public key in the _domainkey record, and gives the private key to the MAP.
#MAP Responsibilities
MAPs MUST NOT cache a CP's public key in-process beyond the published DNS TTL — a stale public key honoured after rotation creates exactly the post-revocation forgery risk DKIM/PKI is designed to prevent.
To ensure that the DNS records used to verify CSF messages reflect the live public records as closely as possible, MAPs SHOULD operate a local DNS server that mirrors the public DNS entries for _domainkey and _mapkey records in near-real-time, observing the public TTLs published by each CP. The core standard for locally served DNS zones is RFC 6303 — Locally Served DNS Zones.
This is a common operational pattern — for example, an authoritative-only recursive resolver pinned to upstream authoritative servers, or a stub resolver bypassing the OS resolver cache. It is widely deployed software and does not impose new infrastructure cost on MAPs.
Why a local DNS server matters in CSF:
- Reduced latency. DNS lookups become near-local rather than going through general internet resolver chains, lowering per-message verification time.
- Live-or-near-live freshness. The MAP sees public DNS changes as soon as published TTLs allow, rather than being subject to upstream resolver caches that may serve stale records past the published TTL.
- No missed CP key rotations or MAP changes. When a CP rotates its
_domainkeyor updates its_mapkey(e.g. during a MAP change), the MAP picks up the new value at TTL expiry without depending on the behaviour of public recursive resolvers.
Impact if not implemented. A MAP that relies on the default OS resolver cache (or any other shared recursive resolver) may:
- Continue to honour stale
_domainkeyrecords — accepting signatures verified against a CP's previous public key after the CP has rotated. This is the primary attack surface for post-revocation key forgery and is the single largest reason the local-DNS-server recommendation matters. - Route via a stale
_mapkey— sending messages or registering counterparties at the CP's previous MAP after the CP has moved. The destination MAP's9301reject path eventually corrects this, but only at the cost of delivery latency and false-failure noise on inter-MAP telemetry. - Misdiagnose its own DNS-related
8102 TEMP_FAILrejections — opaque upstream-resolver behaviour can make transient DNS lookups look like systemic issues, hampering operational triage and slowing incident response.
MAPs that choose not to operate a local DNS server SHOULD nevertheless document the operational implications they have accepted (the cache TTL of their chosen recursive resolver, their monitoring of stale-data incidents) and SHOULD treat the resulting freshness gap as a known operational risk in their MAP MoU with CPs.
#3.4 DKIM Signature Process
The signing process follows RFC 6376:
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:
| Term | Description |
|---|---|
body-hash | Hash of the message body using the specified algorithm |
hash-alg | The hashing algorithm (from the a parameter) |
canon-body | Canonicalised body using the algorithm in the c parameter |
data-hash | Hash of headers + DKIM-Signature header + body hash |
h-headers | Headers listed in the h parameter, to be included in the signature |
D-SIG | The DKIM-Signature header without the b value |
signature | The final signature value |
sig-alg | The signing algorithm (from the a parameter) |
d-domain | The domain from the d parameter |
selector | The selector from the s parameter (always the CP's RCPID) |
Note: The
lparameter from RFC 6376 has been removed. While helpful for large emails, it is not relevant for CSF messages that are typically a few hundred KB at most.
#3.5 CSF HTTP Headers
The CSF uses two HTTP headers for message signing:
| Header | Purpose | Format |
|---|---|---|
X-CSF-SIGNATURE-DATESTAMP | Timestamp of signature creation | yyyyMMddHHmmssS (S = tenth of a second) |
X-CSF-SIGNATURE | The DKIM signature | See structure below |
#X-CSF-SIGNATURE Structure
Example:
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+...
#Mandatory Tags
| Tag | Description |
|---|---|
a | Algorithms used for hashing and encryption (e.g., rsa-sha256) |
s | Selector — always the CP's RCPID |
c | Canonicalisation algorithms — always simple/simple for CSF HTTP messages |
d | Domain for DNS public key lookup |
h | Header fields included in the signature (must include X-CSF-SIGNATURE-DATESTAMP) |
bh | Base64 hash of the message body |
b | Base64 signature data |
#Mandatory Tags with Fixed Values
| Tag | Value | Description |
|---|---|---|
v | 1 | DKIM version |
#Optional Tags with Fixed Values
| Tag | Value | Description |
|---|---|---|
q | dns/txt | Method of acquiring the public key |
#DKIM-Signature Canonicalisation
During signature creation and verification, the DKIM-Signature MUST be formatted as:
DKIM-Signature: <signature value>
Important: Note the exact casing, the colon immediately after the header name, and the single space (ASCII 0x20) after the colon. Any variance will cause valid signatures to fail verification.
#3.6 Supported Algorithms
#Encryption and Signing
| Algorithm | Hash | a Tag Value |
|---|---|---|
| RSA | SHA2-256 | rsa-sha256 |
| RSA | SHA2-512 | rsa-sha512 |
| Ed25519 | SHA2-256 | ed25519-sha256 |
| Ed25519 | SHA2-512 | ed25519-sha512 |
Note: SHA-1 has been removed as it is no longer secure. SHA-3 is not used.
#Key Length Requirements
- RSA: Minimum 2048-bit keys. Verifiers SHOULD reject anything smaller. Avoid RSA-4096 for DKIM unless mandated, due to higher CPU cost and potential DNS/UDP size issues.
- Ed25519: Fixed 256-bit keys only (no size choice). Ed25519 keys are significantly shorter than RSA keys.
#DNS Key Examples
RSA 2048-bit public key TXT record:
k=rsa; p=MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA6/RekuGAsET2Ir8E4K5BU74MBXe
bo7QwrOnSGirtNgRLDcwP2R84Eeum9BLxZtiumUrmO6ja7DrteSZhzOyC09KM3b22zV43bm7nv/FF40+
KpTDQ/FZ32W838UIZGgHOyICk37hlPvnGrVPg7rT6HAKsaj5W0zbvO5vKDUOz2r3bvrWo79YSxtVfGQ1
pHhhZYV2dL1C0uiADbz5L1Cu0qCBjqGJQbYq4ALdaKTnAm2NG/N9W2i3IWJUQIyiTr11qtBrrR8sj2Yh
Yeph6GTsOXggpAWZnEHZz+LKzD4roVqUxkByLWRLB2I5Ju/xPPtTjCDakfvGlsiWl5BMAhn79p0mW6hz
cA4DT7Iz+nSV8K3XMU4rxs0AcaafcBsKREmfbGz4K3ycWHZ/Yrxb+PqYHy7NSK76mHsAjuU8nuKYtZGI
YHwT4friS2R3HtXJ+olLmdZ+XnkpdvEGDb+ZVv/G9vYp020LZQBBd0hp1Ez8a/3F7jRt8xUJ0ljstWjb
q+m3bAgMBAAE=
Ed25519 public key TXT record:
k=ed25519; p=MCowBQYDK2VwAyEAqiCrokPReIFI1h4Jdp7RCRRVQ4Fltdyn429O4H2jYog=
#Canonicalisation
The CSF uses simple/simple canonicalisation for both headers and body. This is the most straightforward implementation — no post-processing of headers or body content is required.
#3.7 Public Key Storage
The public key is stored as a DNS TXT record following DKIM standards.
Domain format: [s]._domainkey.[d]
Where s and d are the values from the X-CSF-SIGNATURE header. The s value is always the CP's RCPID.
Example: 809b6e65-a6e7-40f6-8b52-04dd65b6fce1._domainkey.gplb-test.nowyoyo.net
The CP's RCPID MUST also match an entry in the directory, which defines the valid domain (d) for that CP's public key. Any request where d does not match the directory entry MUST be rejected.
#3.8 Worked Examples
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. See Higher Level Libraries
#3.8.1 Java — Creating a Signature
public String createSignature() throws Exception {
byte[] httpBody = """
{
"test_field": "Test Data"
}""".getBytes();
String privateKeyPEM = "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";
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;
}
#3.8.2 Java — Verifying a Signature
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);
}
#3.8.3 C# — Complete Sign and Verify Example
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Security;
using System.Security.Cryptography;
// === SIGNING ===
string csfTimestamp = DateTime.Now.ToString("yyyyMMddHHmmssf");
string cpId = "809b6e65-a6e7-40f6-8b52-04dd65b6fce1";
string hValue = "X-CSF-SIGNATURE-DATESTAMP";
// Parse private key
byte[] privateKeyBytes = Convert.FromBase64String(privateKeyPEM);
AsymmetricKeyParameter privateKey = PrivateKeyFactory.CreateKey(privateKeyBytes);
// Hash the message body
SHA256 sha256 = SHA256.Create();
byte[] messageBytes = System.Text.Encoding.ASCII.GetBytes(GetMessage());
byte[] messageBodyHashBytes = sha256.ComputeHash(messageBytes);
string bh = Convert.ToBase64String(messageBodyHashBytes);
// Build signature template
string signature = $"a=rsa-sha256; q=dns/txt; c=simple/simple; "
+ $"s={cpId}; d=gplb.onetouchgateway.net; v=1; "
+ $"h={hValue}; bh={bh};";
// Organise data to sign
string dataToSign = csfTimestamp + "\r\n" + "DKIM-Signature: " + signature + " b=";
// Sign with RSA private key
ISigner signer = SignerUtilities.GetSigner("SHA256withRSA");
signer.Init(true, privateKey);
signer.BlockUpdate(
System.Text.Encoding.ASCII.GetBytes(dataToSign), 0, dataToSign.Length
);
byte[] signatureBytes = signer.GenerateSignature();
string csfSignature = signature + " b=" + Convert.ToBase64String(signatureBytes);
// === VERIFICATION ===
// Parse public key
byte[] publicKeyBytes = Convert.FromBase64String(publicKeyString);
AsymmetricKeyParameter publicKey = PublicKeyFactory.CreateKey(publicKeyBytes);
// Get unsigned portion
string unsignedSignature = csfSignature.Substring(0, csfSignature.IndexOf(" b="));
// Verify body hash
byte[] verifyBytes = System.Text.Encoding.ASCII.GetBytes(GetMessage());
string bhVerify = Convert.ToBase64String(sha256.ComputeHash(verifyBytes));
string bhInSig = /* extract bh from signature */;
if (bhInSig != bhVerify) throw new Exception("Hash mismatch!");
// Verify signature
string dataToVerify = csfTimestamp + "\r\n"
+ "DKIM-Signature: " + unsignedSignature + " b=";
ISigner verifier = SignerUtilities.GetSigner("SHA256withRSA");
verifier.Init(false, publicKey);
verifier.BlockUpdate(
System.Text.Encoding.ASCII.GetBytes(dataToVerify), 0, dataToVerify.Length
);
byte[] sigBytes = Convert.FromBase64String(
csfSignature.Substring(csfSignature.IndexOf(" b=") + 3)
);
bool isVerified = verifier.VerifySignature(sigBytes);
#3.8.4 Higher-Level Libraries
Developers can use existing DKIM libraries with minor modifications, removing the need for low-level implementation:
- Java: Apache James jDKIM
- .NET: MailKit
#3.9 Error Codes
If a message fails signature verification, the receiving MAP MUST return one of the following error codes in a synchronous response body with HTTP 403 status:
| Error Code | Meaning | Retry? |
|---|---|---|
8101 | PERM_FAIL — The message failed verification. Retrying will not resolve the issue. | No |
8102 | TEMP_FAIL — The message failed verification due to a temporary problem (e.g., DNS failure). | Yes (with backoff) |
An
8102response suggests a retry may resolve the issue, but does not guarantee it. The CP/MAP MUST have controls in place to handle delivery exceptions. Further details are in the CSF message delivery policy.
#Response Format
{
"errorText": "<description of the failure>",
"errorCode": 8101
}
#Example Responses
PERM_FAIL — Invalid domain for CP:
{
"errorText": "Domain gplb-test.nowyoyo.net is not valid key source for CP 809b6e65-a6e7-40f6-8b52-04dd65b6fce1",
"errorCode": 8101
}
PERM_FAIL — Body hash mismatch:
{
"errorText": "Body Hash 3hzlA9zA8SAVapmd5ZZnMdwaZpk/WOyQXixrqVu/WMc= is different to signature /CWGLVBT2gdyO5cudfAEUSF43KHLmlJjU/Nr2YNqkos=",
"errorCode": 8101
}
PERM_FAIL — Missing mandatory tags:
{
"errorText": "Signature has missing mandatory tag(s): [b, bh]",
"errorCode": 8101
}
TEMP_FAIL — DNS resolution failure:
{
"errorText": "Unable to resolve 809b6e65-a6e7-40f6-8b52-04dd65b6fce1._domainkey.gplb-test.nowyoyo.net. TXT entry does not exist.",
"errorCode": 8102
}
TEMP_FAIL — Cannot extract public key:
{
"errorText": "Unable to resolve 809b6e65-a6e7-40f6-8b52-04dd65b6fce1._domainkey.gplb-test.nowyoyo.net. Cannot get public key",
"errorCode": 8102
}
#3.10 Replay Attack Protection
The X-CSF-SIGNATURE-DATESTAMP header provides protection against replay attacks. The timestamp is embedded in the encrypted signature and cannot be tampered with independently.
Receiving MAPs SHOULD validate the timestamp based on the message type — rejecting messages with timestamps that are unreasonably old.
#3.11 Passthrough Headers
If a MAP is passing a message on to a third party (e.g., TOTSCo operating as a SforB HMAP forwarding to its CPs), the X-CSF-SIGNATURE header SHOULD be left intact. This provides additional trust that the message has not been tampered with since it left the originating CP.
#3.12 Route Tracking
X_CSF_ROUTE is an optional but recommended HTTP header that records the route a message has taken through the network.
Format: {Epoch milliseconds} {MAP Name} (alphanumerics only: [A-Za-z0-9])
When a message passes through a MAP, a new X_CSF_ROUTE header SHOULD be added (not appended to existing headers).
Examples:
X_CSF_ROUTE: 1741947045684 TheYellowMap
X_CSF_ROUTE: 1741947056789 AnotherMap
In most cases there will be a single entry. When a message passes through a forwarding MAP (e.g., TOTSCo), multiple headers will be present, allowing the final destination to diagnose the route taken while still validating (using PKI) that the message came from the originator.
#3.13 Outbound Conflict Resolution
The DNS records used for message signing also resolve routing conflicts. If a CP appears in more than one MAP directory simultaneously (e.g., during a MAP transition):
- The sending MAP looks up
[RCPID]._mapkey.[cp-domain]in DNS - The DNS record indicates the correct MAP endpoint
- The message is routed to the MAP indicated by DNS
It is the CP's responsibility to update their DNS entries and remove outdated records that could result in invalid routing.
Note: CPs having more than one MAP during transition MUST have a new RCPID with the new MAP.
#3.14 Testing Tool
A verification and testing tool is available for developers implementing PKI signing:
Documentation: CSF Verification Testing Endpoint