Skip to content
Connected Services Framework
Part 2 — Implementation
Markdown

#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:

  1. The originator is who they claim to be
  2. The MAP is authorised to act on behalf of the CP
  3. 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 _domainkey record enables signature verification by any receiving MAP
  • The _mapkey record 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 _domainkey or 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 _domainkey records — 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's 9301 reject 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_FAIL rejections — 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:

TermDescription
body-hashHash of the message body using the specified algorithm
hash-algThe hashing algorithm (from the a parameter)
canon-bodyCanonicalised body using the algorithm in the c parameter
data-hashHash of headers + DKIM-Signature header + body hash
h-headersHeaders listed in the h parameter, to be included in the signature
D-SIGThe DKIM-Signature header without the b value
signatureThe final signature value
sig-algThe signing algorithm (from the a parameter)
d-domainThe domain from the d parameter
selectorThe selector from the s parameter (always the CP's RCPID)

Note: The l parameter 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:

HeaderPurposeFormat
X-CSF-SIGNATURE-DATESTAMPTimestamp of signature creationyyyyMMddHHmmssS (S = tenth of a second)
X-CSF-SIGNATUREThe DKIM signatureSee 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

TagDescription
aAlgorithms used for hashing and encryption (e.g., rsa-sha256)
sSelector — always the CP's RCPID
cCanonicalisation algorithms — always simple/simple for CSF HTTP messages
dDomain for DNS public key lookup
hHeader fields included in the signature (must include X-CSF-SIGNATURE-DATESTAMP)
bhBase64 hash of the message body
bBase64 signature data

#Mandatory Tags with Fixed Values

TagValueDescription
v1DKIM version

#Optional Tags with Fixed Values

TagValueDescription
qdns/txtMethod 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

AlgorithmHasha Tag Value
RSASHA2-256rsa-sha256
RSASHA2-512rsa-sha512
Ed25519SHA2-256ed25519-sha256
Ed25519SHA2-512ed25519-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:

#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 CodeMeaningRetry?
8101PERM_FAIL — The message failed verification. Retrying will not resolve the issue.No
8102TEMP_FAIL — The message failed verification due to a temporary problem (e.g., DNS failure).Yes (with backoff)

An 8102 response 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):

  1. The sending MAP looks up [RCPID]._mapkey.[cp-domain] in DNS
  2. The DNS record indicates the correct MAP endpoint
  3. 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