Since Ralphie decoded "Drink your Ovaltine" and Cap'n Crunch put decoder rings in his cereal, I have had a fascination with codes. I was intrigued to learn that a body hash was hiding right there in an email — and so were the decoding instructions: the algorithm (rsa-sha256), the canonicalization method (relaxed/relaxed), and the exact header fields used to compute it. Like finding the decoder ring and the secret message in the same cereal box! I searched high and low and couldn't really find good instructions on how to verify it yourself — until I met my friend Claude...
So we built a decoder ring — one that speaks RFC 6376.
A Python tool for fully verifying DKIM (DomainKeys Identified Mail) signatures on raw .eml files, implementing RFC 6376. Drop in any email, and it verifies both the body hash (bh=) and the RSA header signature (b=) via live DNS key lookup — no manual header extraction required.
| Light Mode | Dark Mode |
![]() |
![]() |
- The Secret Code
- Project Structure
- Prerequisites
- How to Use
- Step-by-Step Decode Walkthrough
- Privacy & Safety
- Workflow & Implementation
- Testing
- License
- Contact/Connect
Every email you receive carries a hidden signature. The sending mail server signs it before delivery, and any recipient — or anyone curious enough to look — can verify it. That standard is DKIM.
The body hash (bh=) is the first layer. The message body is run through a specific recipe called relaxed canonicalization: collapse whitespace, strip trailing spaces from each line, normalize line endings, trim trailing blank lines, add one final line break. Hash the result with SHA-256, base64-encode it, and you get a fingerprint of the message body. That fingerprint is stamped right in the DKIM-Signature header — so the instructions and the answer are both sitting in the same envelope. This is the "Drink your Ovaltine" moment: follow the recipe yourself and see if your answer matches.
Why CRLF is the hidden trap. Email requires \r\n line endings, but most tools silently convert them to \n when saving files. Get this wrong and the hash never matches no matter how correct your math is — the spec gives you no error message, just a silent FAIL. Once you know to look for it, it's one of those "aha" moments that makes the whole puzzle click.
The full signature (b=) goes further. Beyond the body, a set of key headers (From, Subject, Date, etc.) are also signed with RSA. The public key lives in a DNS TXT record at selector._domainkey.domain — publicly available to anyone, like the decoder ring instructions printed on the side of the cereal box. Fetch the key, verify the signature, and you can prove the email genuinely came from the domain it claims.
| File | Purpose |
|---|---|
dkim_verifier.py |
Core library + CLI. All verification logic lives here. |
gui.py |
tkinter GUI — file picker, themed results display, light/dark toggle. |
requirements.txt |
dnspython, cryptography |
verify_bh.py |
Original body-hash-only script — kept as a reference artifact showing the manual approach. |
test_dkim_verifier.py |
Unit + integration tests for dkim_verifier.py (41 tests, no external network calls). |
screenshots/ |
GUI screenshots used in this README. |
Python 3.x
pip3 install dnspython cryptography
# tkinter (GUI only)
sudo dnf install python3-tkinter # Fedora / RHEL
sudo apt install python3-tk # Debian / UbuntuThe GUI mode requires a display backend when running under WSL.
Windows 11 — WSL 2 with WSLg (recommended)
WSLg ships built into Windows 11 (21H2 and later) and renders GUI apps automatically. No extra display setup needed — just run python3 gui.py as normal.
Windows 10 — WSL 2 without WSLg
Install an X server on the Windows side (e.g. VcXsrv), launch it with "Disable access control" checked, then set the display variable before running:
export DISPLAY=$(grep nameserver /etc/resolv.conf | awk '{print $2}'):0.0
python3 gui.pyThe CLI mode (python3 dkim_verifier.py) has no GUI dependency and works in WSL without any display configuration.
python3 gui.pyClick Load .eml File and select any exported email. The body hash result appears immediately (no network needed). The full RSA signature check follows after a DNS lookup — usually a second or two.
Toggle the DARK switch in the top-right corner for the cyber theme.
python3 dkim_verifier.py "my_email.eml"This tool works with any email that carries a DKIM-Signature header — Gmail, Outlook, Yahoo, SendGrid, Mailchimp, and most other modern mail providers sign their outgoing mail.
| Client | How to export |
|---|---|
| Gmail | ⋮ menu → Download message |
| Outlook (web) | ⋮ menu → Save as |
| Thunderbird | Right-click the email → Save As |
| Apple Mail | File → Save As → .eml |
For a comprehensive guide covering dozens of clients — including Outlook 2003–2016, ProtonMail, Zoho, Exchange Online, and more — see the MXToolbox Email Headers Guide (credit: MXToolbox).
Note: The MXToolbox guide is focused on viewing and copying headers into their online analyzer. For this tool you need the full
.emlexport (headers and body) — the body is required to verify the hash.
Here is how the tool cracks a real email, decoder-ring style. The example below is from an Outlook-signed message.
1. Find the ring
Open the email and locate the DKIM-Signature: header. Everything you need is printed right there:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=outlook.com;
s=selector1;
h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:...;
bh=2Xz66rWfrVgWAeWuVgoYF3IZCwn50UywwNAlxj/m+Gc=;
b=f27A6Pa2...
The algorithm (a=), the canonicalization recipe (c=), the signed headers (h=), the expected body hash (bh=), and where to fetch the public key (d= + s=) — all in plain sight.
2. Follow the recipe
Apply relaxed canonicalization to the message body:
- Collapse sequences of spaces/tabs to a single space
- Strip trailing whitespace from each line
- Normalize all line endings to
\r\n - Strip trailing empty lines from the body
- Add exactly one trailing
\r\n
3. Hash it
SHA-256 the canonicalized body and base64-encode the result. If it matches bh=, the message body arrived exactly as the sender wrote it.
4. Fetch the codebook
DNS TXT lookup at selector1._domainkey.outlook.com returns the RSA public key — the p= field. It's public information, available to anyone on the internet.
5. Verify the signature
Canonicalize the headers listed in h=, append the DKIM-Signature header itself (with b= emptied), then verify the b= value against the public key using RSA-PKCS1v15 + SHA-256.
Result:
Body Hash (bh=): PASS
Expected: 2Xz66rWfrVgWAeWuVgoYF3IZCwn50UywwNAlxj/m+Gc=
Computed: 2Xz66rWfrVgWAeWuVgoYF3IZCwn50UywwNAlxj/m+Gc=
Signature (b=): PASS
Key fetched from selector1._domainkey.outlook.com
- The
.emlfile is read locally and never transmitted anywhere. - The only network call is a DNS TXT lookup to fetch the signing domain's public key — the exact same lookup every receiving mail server performs automatically when the email is delivered.
- The query reveals only the signing domain and selector, both of which are already public in the
DKIM-Signatureheader.
dkim_verifier.py reads the .eml file as binary, locates the first blank line to split headers from body (handling both \r\n and \n line endings), then walks the header block to find and unfold the DKIM-Signature header. No manual grep or tail required.
The original verify_bh.py documents the manual approach for reference — useful if you want to understand what the tool is doing under the hood before the automation was added.
Per RFC 6376 §3.4.3:
- Collapse all sequences of whitespace to a single space
- Strip trailing whitespace from each line
- Strip trailing empty lines from the body
- Add exactly one trailing
\r\n - SHA-256 → base64 → compare to
bh=
Per RFC 6376 §3.4.2, for each header named in h=:
- Lowercase the header name
- Unfold continuation lines (CRLF+WSP → single space)
- Collapse whitespace sequences
- Strip whitespace around the colon and at the end of the value
- Emit
lowercasename:value\r\n
The DKIM-Signature header is appended last with the b= value emptied, and no trailing CRLF.
The RSA public key is fetched from DNS (selector._domainkey.domain TXT record, p= field), loaded with the cryptography library, and used to verify the b= value with RSA-PKCS1v15 + SHA-256.
The test suite covers all core logic in dkim_verifier.py — parsing, canonicalization, hashing, RSA signature verification, and full end-to-end verify_email integration (with DNS mocked so no network calls are needed).
python3 -m unittest test_dkim_verifier -v41 tests across 8 test classes:
| Class | Coverage |
|---|---|
TestParseEml |
CRLF/LF separators, empty body, missing separator error |
TestSplitHeaders |
Simple and folded (tab/space continuation) headers |
TestParseDkimSignature |
All tag parsing, folded headers, case-insensitive match, missing header error |
TestCanonicalizeHeader |
Lowercasing, whitespace collapsing, unfolding, stripping |
TestCanonicalizeBodyRelaxed |
All RFC 6376 §3.4.3 rules — CRLF normalization, whitespace, trailing blank lines |
TestVerifyBodyHash |
SHA-256 and SHA-1 paths, match/mismatch, tamper detection |
TestVerifySignature |
Valid RSA signature, invalid signature, tampered data, whitespace in b= value |
TestVerifyEmail |
Full integration: bh= + b= pass, wrong bh=, DNS failure, missing DKIM header |
verify_bh.py is a hardcoded reference artifact with no importable functions; its logic is fully exercised through the dkim_verifier.py tests. The tkinter GUI (gui.py) requires a live display and is not covered by automated tests.
This project is licensed under the MIT License — see the LICENSE file for details.
Caine Pavlosky
- Email: cainepavl@outlook.com
- Portfolio: fairdinkumstudios.com
- LinkedIn: linkedin.com/in/cainepavlosky008

