DKIM / SPF / DMARC Builder & Checker — C source
Build the three email-authentication DNS records — SPF, DKIM, and DMARC — as ready-to-paste TXT values, or look up a domain's live records over DNS-over-HTTPS. Import existing records, catch publishing mistakes, share via URL. Entirely client-side.
This is the C implementation — the same logic the interactive tool runs, in a shareable, citable form.
/*
* dkim-spf-dmarc — email authentication (SPF / DKIM / DMARC) record parsing.
*
* Language: C (C11, POSIX) + the C stdlib only (the parsers are pure string
* work; the TS reference needs no crypto either).
* Source: CosmoDev polyglot showcase port of the dkim-spf-dmarc tool,
* ported from src/lib/dkim-spf-dmarc.ts (the canonical TypeScript
* implementation).
* License: display source — part of CosmoDev's polyglot tool pages.
*
* Like the TS reference, this file is two layers:
* - Pure parsers (parse_spf / parse_dkim / parse_dmarc) — deterministic,
* never fail, testable without network.
* - The TS check functions (checkSPF / checkDKIM / checkDMARC) add one
* DNS-over-HTTPS fetch to Cloudflare's resolver (browser `fetch`). C has
* no fetch; obtain the TXT records with your platform resolver (c-ares,
* res_query, libcurl + https://cloudflare-dns.com/dns-query) and feed
* the newline-joined strings to the parsers — the evaluation/status
* logic that sits on top of the parse is included below.
*
* Specs: SPF RFC 7208, DKIM RFC 6376, DMARC RFC 7489.
*
* Build: cc -std=c11 dkim-spf-dmarc.c
*/
#define _POSIX_C_SOURCE 200809L
#include <ctype.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* --------------------------------------------------------------- types --- */
typedef enum { STATUS_PASS, STATUS_WARN, STATUS_FAIL } check_status;
typedef enum { QUAL_PLUS, QUAL_MINUS, QUAL_TILDE, QUAL_QUESTION } spf_qualifier;
typedef enum {
MECH_ALL, MECH_INCLUDE, MECH_A, MECH_MX, MECH_IP4, MECH_IP6, MECH_EXISTS, MECH_PTR,
} spf_mechanism_kind;
typedef struct {
spf_qualifier qualifier;
spf_mechanism_kind kind;
char *value; /* domain / IP / CIDR argument after the colon; NULL when absent */
} spf_mechanism;
typedef struct {
bool valid;
char *version; /* NULL when no v=spf1 line was found */
spf_mechanism *mechanisms;
size_t mechanism_count;
char *redirect; /* NULL when absent */
char *exp; /* NULL when absent */
/** Mechanisms that cost a DNS query under RFC 7208 §4.6.4 (limit 10). */
size_t lookup_count;
/** Number of `v=spf1` records found (more than one is a hard error). */
size_t record_count;
char **warnings;
size_t warning_count;
} spf_record;
typedef struct {
bool valid;
char *version; /* uppercased v= tag; NULL when absent */
char *key_type; /* k= tag, default "rsa" */
char *public_key; /* base64, whitespace-stripped; NULL until read */
/** Approximate modulus size in bits (RSA only; derived from DER length). -1 when unknown. */
long key_bits;
char **hashes; size_t hash_count; /* h= split on ':' */
char **services; size_t service_count; /* s= split on ':' */
char **flags; size_t flag_count; /* t= split on ':' */
char **warnings;
size_t warning_count;
} dkim_record;
typedef enum { DMARC_NONE, DMARC_QUARANTINE, DMARC_REJECT } dmarc_policy;
typedef struct {
bool valid;
int policy; /* dmarc_policy; -1 when absent */
int subdomain_policy; /* sp= tag; -1 when absent/invalid */
char **aggregate_uris; size_t aggregate_count; /* rua= */
char **forensic_uris; size_t forensic_count; /* ruf= */
long percent; /* pct=; -1 when absent */
int dkim_alignment; /* adkim= 'r'|'s'; 0 when absent */
int spf_alignment; /* aspf= 'r'|'s'; 0 when absent */
char **warnings;
size_t warning_count;
} dmarc_record;
/* ------------------------------------------------------------- helpers --- */
static void *xrealloc(void *buf, size_t n) {
void *p = realloc(buf, n);
if (!p) { fprintf(stderr, "out of memory\n"); exit(1); }
return p;
}
static char *xstrdup(const char *s) {
size_t n = strlen(s) + 1;
char *p = xrealloc(NULL, n);
memcpy(p, s, n);
return p;
}
static char *xstrndup(const char *s, size_t n) {
char *p = xrealloc(NULL, n + 1);
memcpy(p, s, n);
p[n] = '\0';
return p;
}
/** printf-style string builder (returns a fresh malloc'd string). */
static char *strf(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(NULL, 0, fmt, ap);
va_end(ap);
if (n < 0) return xstrdup("");
char *out = xrealloc(NULL, (size_t)n + 1);
va_start(ap, fmt);
vsnprintf(out, (size_t)n + 1, fmt, ap);
va_end(ap);
return out;
}
static void push_string(char ***list, size_t *count, char *owned) {
*list = xrealloc(*list, (*count + 1) * sizeof(char *));
(*list)[(*count)++] = owned;
}
static void push_warning(char ***warnings, size_t *count, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(NULL, 0, fmt, ap);
va_end(ap);
char *w = n < 0 ? xstrdup("") : xrealloc(NULL, (size_t)n + 1);
if (n >= 0) {
va_start(ap, fmt);
vsnprintf(w, (size_t)n + 1, fmt, ap);
va_end(ap);
}
push_string(warnings, count, w);
}
/** Trim in place by returning the first non-space offset and NUL-terminating
* the trailing spaces of a mutable copy. */
static char *trim_mut(char *s) {
while (*s && isspace((unsigned char)*s)) s++;
size_t end = strlen(s);
while (end > 0 && isspace((unsigned char)s[end - 1])) s[--end] = '\0';
return s;
}
static char *lowercase_dup(const char *s) {
char *out = xstrdup(s);
for (char *c = out; *c; c++) *c = (char)tolower((unsigned char)*c);
return out;
}
static bool ci_equals(const char *a, const char *b) {
while (*a && *b) {
if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return false;
a++; b++;
}
return *a == '\0' && *b == '\0';
}
/** True when s starts with prefix, ignoring case. */
static bool ci_starts_with(const char *s, const char *prefix) {
size_t n = strlen(prefix);
if (strlen(s) < n) return false;
for (size_t i = 0; i < n; i++) {
if (tolower((unsigned char)s[i]) != tolower((unsigned char)prefix[i])) return false;
}
return true;
}
/** Split a mutable string on sep into trimmed, malloc'd pieces. */
static void split_all(char *s, char sep, char ***out, size_t *count) {
*out = NULL;
*count = 0;
char *p = s;
while (p) {
char *next = strchr(p, sep);
if (next) *next = '\0';
push_string(out, count, xstrdup(trim_mut(p)));
p = next ? next + 1 : NULL;
}
}
/* ------------------------------------------------------- shared helpers --- */
/**
* Strip a leading scheme, userinfo, path, query, port, and trailing dot from
* user input (the TS normalizeDomain). Returns a fresh lowercase string.
*/
static char *normalize_domain(const char *raw) {
char *s = xstrdup(raw);
char *p = trim_mut(s);
/* scheme:// — [a-z][a-z0-9+.-]*:\/\/ */
if (p[0] && isalpha((unsigned char)p[0])) {
size_t i = 1;
while (p[i] && (isalnum((unsigned char)p[i]) || p[i] == '+' || p[i] == '.' || p[i] == '-')) i++;
if (i >= 1 && p[i] == ':' && p[i + 1] == '/' && p[i + 2] == '/') {
p += i + 3;
}
}
/* mailto:user@domain */
if (ci_starts_with(p, "mailto:")) p += 7;
/* keep host of user@host */
char *at = strrchr(p, '@');
if (at) p = at + 1;
/* drop path, query, port */
char *cut = strpbrk(p, "/?:");
if (cut) *cut = '\0';
/* trailing dot(s) */
size_t end = strlen(p);
while (end > 0 && p[end - 1] == '.') p[--end] = '\0';
char *out = lowercase_dup(p);
free(s);
return out;
}
/** Domain/label character tests — lowercase only, like the TS DOMAIN_RE. */
static bool is_dchar(char c) {
unsigned char u = (unsigned char)c;
return islower(u) || isdigit(u);
}
/** True when the string looks like a plausible multi-label domain
* (example.com): labels of [a-z0-9-] not starting/ending with '-', the last
* label alphabetic and >= 2 chars, total length <= 253. */
static bool is_domain_like(const char *domain) {
size_t n = strlen(domain);
if (n == 0 || n > 253 || !is_dchar(domain[0])) return false;
size_t label_start = 0;
bool last_alpha = false;
for (size_t i = 0; i <= n; i++) {
char c = domain[i];
if (c == '.' || c == '\0') {
size_t len = i - label_start;
if (len == 0) return false;
if (domain[i - 1] == '-') return false; /* label ends in '-' */
if (c == '.') {
if (i + 1 >= n || !is_dchar(domain[i + 1])) return false; /* '-x.' or 'a..b' */
} else {
/* final label must be all letters, length >= 2 */
if (len < 2) return false;
for (size_t j = label_start; j < i; j++) {
if (!islower((unsigned char)domain[j])) return false;
}
last_alpha = true;
}
label_start = i + 1;
} else if (!is_dchar(c) && c != '-') {
return false;
}
}
return last_alpha; /* at least one '.' was seen before the final label */
}
/** True when the selector is a safe single DNS label chain (no spaces, no
* traversal): [a-z0-9][a-z0-9._-]* , length 1..100, no "..". */
static bool is_valid_selector(const char *selector) {
char *orig = xstrdup(selector);
char *trimmed = trim_mut(orig);
size_t n = strlen(trimmed);
bool ok = n > 0 && n <= 100 && isalnum((unsigned char)trimmed[0])
&& !strstr(trimmed, "..");
for (size_t i = 0; ok && i < n; i++) {
if (!isalnum((unsigned char)trimmed[i])
&& trimmed[i] != '.' && trimmed[i] != '_' && trimmed[i] != '-') ok = false;
}
free(orig); /* trimmed may point inside orig */
return ok;
}
/* ------------------------------------------------------- SPF — RFC 7208 --- */
static bool spf_kind_from_name(const char *name, spf_mechanism_kind *out) {
static const struct { const char *name; spf_mechanism_kind kind; } TABLE[] = {
{"all", MECH_ALL}, {"include", MECH_INCLUDE}, {"a", MECH_A}, {"mx", MECH_MX},
{"ip4", MECH_IP4}, {"ip6", MECH_IP6}, {"exists", MECH_EXISTS}, {"ptr", MECH_PTR},
};
for (size_t i = 0; i < sizeof TABLE / sizeof TABLE[0]; i++) {
if (ci_equals(name, TABLE[i].name)) { *out = TABLE[i].kind; return true; }
}
return false;
}
static bool spf_kind_costs_lookup(spf_mechanism_kind kind) {
return kind == MECH_INCLUDE || kind == MECH_A || kind == MECH_MX
|| kind == MECH_EXISTS || kind == MECH_PTR;
}
static void spf_record_free(spf_record *r) {
free(r->version);
for (size_t i = 0; i < r->mechanism_count; i++) {
free(r->mechanisms[i].value);
}
free(r->mechanisms);
free(r->redirect);
free(r->exp);
for (size_t i = 0; i < r->warning_count; i++) free(r->warnings[i]);
free(r->warnings);
memset(r, 0, sizeof *r);
}
/** Parse one or more (newline-joined) TXT record strings for SPF. Pure. */
static void parse_spf(const char *txt, spf_record *record) {
memset(record, 0, sizeof *record);
char *copy = xstrdup(txt);
char *p = copy;
/* Collect lines, strip one pair of surrounding quotes, keep v=spf1 ones. */
char **spf_lines = NULL;
size_t spf_line_count = 0;
while (p) {
char *next = strchr(p, '\n');
if (next) *next = '\0';
char *line = trim_mut(p);
size_t len = strlen(line);
if (len >= 2 && line[0] == '"' && line[len - 1] == '"') {
line[len - 1] = '\0';
line++;
}
line = trim_mut(line);
if (line[0] != '\0' && ci_starts_with(line, "v=spf1")
&& (line[6] == '\0' || isspace((unsigned char)line[6]))) {
push_string(&spf_lines, &spf_line_count, xstrdup(line));
}
p = next ? next + 1 : NULL;
}
free(copy);
record->record_count = spf_line_count;
if (spf_line_count == 0) {
push_warning(&record->warnings, &record->warning_count,
"No v=spf1 record found in the supplied text.");
return;
}
if (spf_line_count > 1) {
push_warning(&record->warnings, &record->warning_count,
"%zu SPF records found — RFC 7208 allows exactly one. Receivers treat "
"this as a permanent error and ignore SPF for the whole domain.",
spf_line_count);
}
/* Tokenise the first record on whitespace runs. */
char *terms[128];
size_t term_count = 0;
char *t = spf_lines[0];
while (*t && term_count < 128) {
while (*t && isspace((unsigned char)*t)) t++;
if (!*t) break;
char *start = t;
while (*t && !isspace((unsigned char)*t)) t++;
if (*t) *t++ = '\0';
terms[term_count++] = start;
}
record->version = xstrdup(terms[0]);
for (size_t i = 1; i < term_count; i++) {
char *term = terms[i];
/* Modifiers use '=': redirect= and exp= — ^([a-z][a-z0-9-]*)=(.*)$ */
char *eq = strchr(term, '=');
if (eq && eq != term) {
bool name_ok = isalpha((unsigned char)term[0]);
for (char *c = term + 1; name_ok && c < eq; c++) {
if (!isalnum((unsigned char)*c) && *c != '-') name_ok = false;
}
if (name_ok) {
char *name_raw = xstrndup(term, (size_t)(eq - term));
char *name = lowercase_dup(name_raw);
free(name_raw);
if (strcmp(name, "redirect") == 0) {
free(record->redirect);
record->redirect = xstrdup(eq + 1);
} else if (strcmp(name, "exp") == 0) {
free(record->exp);
record->exp = xstrdup(eq + 1);
} else {
push_warning(&record->warnings, &record->warning_count,
"Unknown modifier \"%s\" ignored.", term);
}
free(name);
continue;
}
}
/* Mechanism: ^([+\-~?])?([a-z0-9]+)(?::(.*))?$ */
char *q = term;
spf_qualifier qual = QUAL_PLUS;
if (*q == '+' || *q == '-' || *q == '~' || *q == '?') {
qual = *q == '+' ? QUAL_PLUS : *q == '-' ? QUAL_MINUS
: *q == '~' ? QUAL_TILDE : QUAL_QUESTION;
q++;
}
char *kind_start = q;
while (*q && isalnum((unsigned char)*q)) q++;
size_t kind_len = (size_t)(q - kind_start);
char *value = NULL;
if (*q == ':') value = xstrdup(q + 1);
else if (*q != '\0') {
push_warning(&record->warnings, &record->warning_count,
"Unrecognized term \"%s\" ignored.", term);
continue;
}
if (kind_len == 0) {
push_warning(&record->warnings, &record->warning_count,
"Unrecognized term \"%s\" ignored.", term);
free(value);
continue;
}
char *kind_raw = xstrndup(kind_start, kind_len);
char *kind_lower = lowercase_dup(kind_raw);
free(kind_raw);
spf_mechanism_kind kind;
if (!spf_kind_from_name(kind_lower, &kind)) {
push_warning(&record->warnings, &record->warning_count,
"Unknown mechanism \"%s\" ignored.", term);
free(value);
free(kind_lower);
continue;
}
if (!value && (kind == MECH_INCLUDE || kind == MECH_EXISTS)) {
push_warning(&record->warnings, &record->warning_count,
"Mechanism \"%s\" is missing its required value.", term);
free(kind_lower);
continue;
}
record->mechanisms = xrealloc(record->mechanisms,
(record->mechanism_count + 1) * sizeof(spf_mechanism));
spf_mechanism *mech = &record->mechanisms[record->mechanism_count++];
mech->qualifier = qual;
mech->kind = kind;
mech->value = value;
free(kind_lower);
}
size_t lookups = 0;
for (size_t i = 0; i < record->mechanism_count; i++) {
if (spf_kind_costs_lookup(record->mechanisms[i].kind)) lookups++;
}
if (record->redirect) lookups++;
record->lookup_count = lookups;
const spf_mechanism *all = NULL;
for (size_t i = 0; i < record->mechanism_count; i++) {
if (record->mechanisms[i].kind == MECH_ALL) { all = &record->mechanisms[i]; break; }
}
if (!all && !record->redirect) {
push_warning(&record->warnings, &record->warning_count,
"No \"all\" mechanism and no \"redirect=\" — unmatched senders get a "
"Neutral result, so anyone can still send mail that looks like this domain.");
}
if (all && all->qualifier == QUAL_PLUS) {
push_warning(&record->warnings, &record->warning_count,
"\"+all\" explicitly allows every host on the internet to send mail as "
"this domain — this defeats SPF entirely.");
} else if (all && all->qualifier == QUAL_QUESTION) {
push_warning(&record->warnings, &record->warning_count,
"\"?all\" (Neutral) lets unmatched senders through with no protection. "
"Prefer \"~all\" or \"-all\".");
}
for (size_t i = 0; i < record->mechanism_count; i++) {
if (record->mechanisms[i].kind == MECH_PTR) {
push_warning(&record->warnings, &record->warning_count,
"The \"ptr\" mechanism is deprecated (RFC 7208 §5.5) and should not be used.");
break;
}
}
if (record->redirect && all) {
push_warning(&record->warnings, &record->warning_count,
"A \"redirect=\" modifier is ignored when an \"all\" mechanism is present.");
}
if (record->lookup_count > 10) {
push_warning(&record->warnings, &record->warning_count,
"%zu DNS-lookup mechanisms — RFC 7208 §4.6.4 caps SPF at 10. Receivers "
"that hit the cap return permerror and ignore the record.",
record->lookup_count);
}
record->valid = true;
for (size_t i = 0; i < spf_line_count; i++) free(spf_lines[i]);
free(spf_lines);
}
/* ------------------------------------------------------- DKIM — RFC 6376 --- */
/** Decode base64 to a byte buffer without failing hard on bad input.
* Mirrors decodeBase64Lenient + atob: whitespace stripped, missing padding
* tolerated, invalid characters rejected. Returns the decoded length or -1. */
static long base64_decode_lenient(const char *b64, uint8_t *out, size_t cap) {
size_t n = strlen(b64);
char *clean = xrealloc(NULL, n + 1);
size_t c = 0;
for (size_t i = 0; i < n; i++) {
if (!isspace((unsigned char)b64[i])) clean[c++] = b64[i];
}
clean[c] = '\0';
long result = -1;
if (c > 0 && c % 4 != 1) { /* impossible length (1 mod 4) fails, like atob */
static const char B64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
bool ok = true;
size_t out_len = 0;
for (size_t i = 0; i < c && ok; i += 4) {
size_t group = (c - i < 4) ? c - i : 4;
int v[4] = {0};
size_t data = 0; /* non-padding chars in this group */
for (size_t j = 0; j < group; j++) {
if (clean[i + j] == '=') break; /* padding ends the input */
const char *pos = strchr(B64, clean[i + j]);
if (!pos) { ok = false; break; }
v[data++] = (int)(pos - B64);
}
if (!ok) break;
if (data >= 2 && out_len < cap) out[out_len++] = (uint8_t)((v[0] << 2) | (v[1] >> 4));
if (data >= 3 && out_len < cap) out[out_len++] = (uint8_t)(((v[1] & 0x0f) << 4) | (v[2] >> 2));
if (data >= 4 && out_len < cap) out[out_len++] = (uint8_t)(((v[2] & 0x03) << 6) | v[3]);
if (data < group) break; /* reached '=' — nothing more to decode */
}
if (ok) result = (long)out_len;
}
free(clean);
return result;
}
static void dkim_record_free(dkim_record *r) {
free(r->version);
free(r->key_type);
free(r->public_key);
for (size_t i = 0; i < r->hash_count; i++) free(r->hashes[i]);
free(r->hashes);
for (size_t i = 0; i < r->service_count; i++) free(r->services[i]);
free(r->services);
for (size_t i = 0; i < r->flag_count; i++) free(r->flags[i]);
free(r->flags);
for (size_t i = 0; i < r->warning_count; i++) free(r->warnings[i]);
free(r->warnings);
memset(r, 0, sizeof *r);
}
/** Look a tag up in a parsed tag list (first occurrence wins, like the TS Map). */
static const char *tag_get(char **keys, char **values, size_t count, const char *key) {
for (size_t i = 0; i < count; i++) {
if (strcmp(keys[i], key) == 0) return values[i];
}
return NULL;
}
/** Parse a `<selector>._domainkey` TXT record. Pure. */
static void parse_dkim(const char *txt, dkim_record *record) {
memset(record, 0, sizeof *record);
record->key_bits = -1;
char *copy = xstrdup(txt);
char **keys = NULL, **values = NULL;
size_t tag_count = 0;
char *p = copy;
while (p) {
char *next = strchr(p, ';');
if (next) *next = '\0';
char *term = trim_mut(p);
size_t len = strlen(term);
if (len >= 2 && term[0] == '"' && term[len - 1] == '"') {
term[len - 1] = '\0';
term++;
term = trim_mut(term);
}
if (term[0] != '\0') {
char *eq = strchr(term, '=');
if (!eq || eq == term) {
push_warning(&record->warnings, &record->warning_count,
"Malformed tag \"%s\" ignored.", term);
} else {
*eq = '\0';
char *key = lowercase_dup(trim_mut(term));
char *value = xstrdup(trim_mut(eq + 1));
push_string(&keys, &tag_count, key);
values = xrealloc(values, tag_count * sizeof(char *));
values[tag_count - 1] = value;
}
}
p = next ? next + 1 : NULL;
}
const char *version = tag_get(keys, values, tag_count, "v");
if (version && !ci_equals(version, "DKIM1")) {
push_warning(&record->warnings, &record->warning_count,
"Unusual version tag v=%s (expected DKIM1).", version);
}
const char *key_type = tag_get(keys, values, tag_count, "k");
record->key_type = xstrdup(key_type ? key_type : "rsa");
if (version) {
record->version = xstrdup(version);
for (char *c = record->version; *c; c++) *c = (char)toupper((unsigned char)*c);
}
/* colon-separated lists: h= / s= / t= */
char **parts = NULL;
size_t part_count = 0;
const char *h = tag_get(keys, values, tag_count, "h");
char *h_copy = xstrdup(h ? h : "");
split_all(h_copy, ':', &parts, &part_count);
for (size_t i = 0; i < part_count; i++) {
if (parts[i][0]) push_string(&record->hashes, &record->hash_count, parts[i]);
else free(parts[i]);
}
free(parts);
free(h_copy);
parts = NULL; part_count = 0;
const char *s = tag_get(keys, values, tag_count, "s");
char *s_copy = xstrdup(s ? s : "");
split_all(s_copy, ':', &parts, &part_count);
for (size_t i = 0; i < part_count; i++) {
if (parts[i][0]) push_string(&record->services, &record->service_count, parts[i]);
else free(parts[i]);
}
free(parts);
free(s_copy);
parts = NULL; part_count = 0;
const char *t = tag_get(keys, values, tag_count, "t");
char *t_copy = xstrdup(t ? t : "");
split_all(t_copy, ':', &parts, &part_count);
for (size_t i = 0; i < part_count; i++) {
if (parts[i][0]) push_string(&record->flags, &record->flag_count, parts[i]);
else free(parts[i]);
}
free(parts);
free(t_copy);
record->valid = true;
const char *pub = tag_get(keys, values, tag_count, "p");
if (!pub) {
record->valid = false;
push_warning(&record->warnings, &record->warning_count,
"No p= tag — this record is not a usable DKIM key.");
goto done;
}
if (pub[0] == '\0') {
push_warning(&record->warnings, &record->warning_count,
"p= is empty — the key is revoked. Receivers will treat mail signed "
"with this selector as unsigned.");
goto done;
}
{
uint8_t decoded[8192];
long decoded_len = base64_decode_lenient(pub, decoded, sizeof decoded);
if (decoded_len < 0) {
push_warning(&record->warnings, &record->warning_count,
"The p= value is not valid base64 — the key could not be read.");
goto done;
}
char *pk = xstrdup(pub);
size_t kept = 0;
for (char *c = pk; *c; c++) {
if (!isspace((unsigned char)*c)) pk[kept++] = *c;
}
pk[kept] = '\0';
record->public_key = pk;
if (strcmp(record->key_type, "rsa") == 0) {
/* SubjectPublicKeyInfo DER ≈ modulus bits/8 + ~24 bytes of ASN.1
* overhead. The estimate is close enough to classify 512/1024/
* 2048/4096-bit keys. */
long bits = (decoded_len - 24 > 0 ? decoded_len - 24 : 0) * 8;
record->key_bits = bits;
if (bits < 1024) {
push_warning(&record->warnings, &record->warning_count,
"Weak RSA key (~%ld bits). Keys under 1024 bits are considered "
"breakable; RFC 8301 discourages short keys.", bits);
} else if (bits < 2048) {
push_warning(&record->warnings, &record->warning_count,
"RSA key of ~%ld bits works today but is below the recommended "
"2048 bits (RFC 8301).", bits);
}
}
for (size_t i = 0; i < record->flag_count; i++) {
if (strcmp(record->flags[i], "y") == 0) {
push_warning(&record->warnings, &record->warning_count,
"t=y — the key is in test mode: receivers must treat signatures "
"as if unsigned.");
} else if (strcmp(record->flags[i], "s") == 0) {
push_warning(&record->warnings, &record->warning_count,
"t=s — strict domain matching: the key cannot be used for "
"subdomain signatures (informational).");
}
}
}
done:
for (size_t i = 0; i < tag_count; i++) { free(keys[i]); free(values[i]); }
free(keys);
free(values);
free(copy);
}
/* ------------------------------------------------------ DMARC — RFC 7489 --- */
static void dmarc_record_free(dmarc_record *r) {
for (size_t i = 0; i < r->aggregate_count; i++) free(r->aggregate_uris[i]);
free(r->aggregate_uris);
for (size_t i = 0; i < r->forensic_count; i++) free(r->forensic_uris[i]);
free(r->forensic_uris);
for (size_t i = 0; i < r->warning_count; i++) free(r->warnings[i]);
free(r->warnings);
memset(r, 0, sizeof *r);
}
static bool dmarc_policy_from_name(const char *name, int *out) {
if (strcmp(name, "none") == 0) { *out = DMARC_NONE; return true; }
if (strcmp(name, "quarantine") == 0) { *out = DMARC_QUARANTINE; return true; }
if (strcmp(name, "reject") == 0) { *out = DMARC_REJECT; return true; }
return false;
}
/** Parse a `_dmarc` TXT record. Pure. */
static void parse_dmarc(const char *txt, dmarc_record *record) {
memset(record, 0, sizeof *record);
record->policy = -1;
record->subdomain_policy = -1;
record->percent = -1;
record->dkim_alignment = 0;
record->spf_alignment = 0;
char *copy = xstrdup(txt);
char **keys = NULL, **values = NULL;
size_t tag_count = 0;
char *p = copy;
while (p) {
char *next = strchr(p, ';');
if (next) *next = '\0';
char *term = trim_mut(p);
size_t len = strlen(term);
if (len >= 2 && term[0] == '"' && term[len - 1] == '"') {
term[len - 1] = '\0';
term++;
term = trim_mut(term);
}
if (term[0] != '\0') {
char *eq = strchr(term, '=');
if (!eq || eq == term) {
push_warning(&record->warnings, &record->warning_count,
"Malformed tag \"%s\" ignored.", term);
} else {
*eq = '\0';
char *key = lowercase_dup(trim_mut(term));
char *value = xstrdup(trim_mut(eq + 1));
push_string(&keys, &tag_count, key);
values = xrealloc(values, tag_count * sizeof(char *));
values[tag_count - 1] = value;
}
}
p = next ? next + 1 : NULL;
}
const char *version = tag_get(keys, values, tag_count, "v");
if (!version) {
record->valid = false;
push_warning(&record->warnings, &record->warning_count,
"No v= tag — this is not a DMARC record.");
goto done;
}
if (!ci_equals(version, "DMARC1")) {
record->valid = false;
push_warning(&record->warnings, &record->warning_count,
"Unknown version v=%s (expected DMARC1).", version);
goto done;
}
{
char *p_lower = NULL;
const char *p_tag = tag_get(keys, values, tag_count, "p");
if (!p_tag || p_tag[0] == '\0') {
record->valid = false;
push_warning(&record->warnings, &record->warning_count,
"No p= policy tag — DMARC requires it.");
goto done;
}
p_lower = lowercase_dup(p_tag);
if (!dmarc_policy_from_name(p_lower, &record->policy)) {
record->valid = false;
push_warning(&record->warnings, &record->warning_count,
"Invalid policy p=%s (expected none, quarantine, or reject).", p_lower);
free(p_lower);
goto done;
}
free(p_lower);
}
{
const char *sp_tag = tag_get(keys, values, tag_count, "sp");
if (sp_tag && sp_tag[0] != '\0') {
char *sp_lower = lowercase_dup(sp_tag);
int sp;
if (dmarc_policy_from_name(sp_lower, &sp)) record->subdomain_policy = sp;
else {
push_warning(&record->warnings, &record->warning_count,
"Invalid sp=%s ignored (expected none, quarantine, or reject).", sp_lower);
}
free(sp_lower);
}
}
{
const char *rua = tag_get(keys, values, tag_count, "rua");
if (rua && rua[0] != '\0') {
char *rua_copy = xstrdup(rua);
char **parts = NULL;
size_t part_count = 0;
split_all(rua_copy, ',', &parts, &part_count);
for (size_t i = 0; i < part_count; i++) {
if (parts[i][0]) push_string(&record->aggregate_uris, &record->aggregate_count, parts[i]);
else free(parts[i]);
}
free(parts);
free(rua_copy);
}
const char *ruf = tag_get(keys, values, tag_count, "ruf");
if (ruf && ruf[0] != '\0') {
char *ruf_copy = xstrdup(ruf);
char **parts = NULL;
size_t part_count = 0;
split_all(ruf_copy, ',', &parts, &part_count);
for (size_t i = 0; i < part_count; i++) {
if (parts[i][0]) push_string(&record->forensic_uris, &record->forensic_count, parts[i]);
else free(parts[i]);
}
free(parts);
free(ruf_copy);
}
}
{
const char *pct = tag_get(keys, values, tag_count, "pct");
if (pct) {
char *end = NULL;
long n = strtol(pct, &end, 10);
if (end == pct || *end != '\0' || n < 0 || n > 100) {
push_warning(&record->warnings, &record->warning_count,
"Invalid pct=%s ignored (must be 0-100).", pct);
} else {
record->percent = n;
}
}
}
{
const char *adkim = tag_get(keys, values, tag_count, "adkim");
if (adkim && adkim[0] != '\0') {
if (strcmp(adkim, "r") == 0 || strcmp(adkim, "s") == 0) {
record->dkim_alignment = adkim[0];
} else {
push_warning(&record->warnings, &record->warning_count,
"Invalid adkim=%s ignored (expected r or s).", adkim);
}
}
const char *aspf = tag_get(keys, values, tag_count, "aspf");
if (aspf && aspf[0] != '\0') {
if (strcmp(aspf, "r") == 0 || strcmp(aspf, "s") == 0) {
record->spf_alignment = aspf[0];
} else {
push_warning(&record->warnings, &record->warning_count,
"Invalid aspf=%s ignored (expected r or s).", aspf);
}
}
}
/* Policy guidance — mirrors the TS exactly. */
if (record->policy == DMARC_NONE) {
push_warning(&record->warnings, &record->warning_count,
"p=none is monitor-only — no mail is quarantined or rejected, but you "
"still need SPF/DKIM to pass for reports to be useful.");
}
if (record->aggregate_count == 0) {
push_warning(&record->warnings, &record->warning_count,
"No rua= address — without aggregate reports you cannot see who is "
"failing authentication. Add rua=mailto:reports@example.com.");
} else if (record->forensic_count > 0) {
push_warning(&record->warnings, &record->warning_count,
"ruf= (forensic reports) is supported by few receivers and may leak "
"message content to the report address (informational).");
}
if (record->percent >= 0 && record->percent < 100 && record->policy != DMARC_NONE) {
push_warning(&record->warnings, &record->warning_count,
"pct=%ld applies the policy to only %ld%% of mail — the other %ld%% is "
"unaffected.", record->percent, record->percent, 100 - record->percent);
}
record->valid = true;
done:
for (size_t i = 0; i < tag_count; i++) { free(keys[i]); free(values[i]); }
free(keys);
free(values);
free(copy);
}
/* ------------------------------------------------- check-layer evaluation --- */
/** Status a parsed record maps to (the pure half of the TS check functions):
* warnings -> warn, otherwise pass. */
static check_status status_from_warnings(size_t warning_count) {
return warning_count > 0 ? STATUS_WARN : STATUS_PASS;
}
static const char *status_name(check_status s) {
return s == STATUS_PASS ? "pass" : s == STATUS_WARN ? "warn" : "fail";
}
/* ------------------------------------------------------------- demo main --- */
static void print_warnings(char **warnings, size_t count) {
for (size_t i = 0; i < count; i++) printf(" - %s\n", warnings[i]);
}
int main(void) {
/* SPF: one include, one ip4, ~all — healthy. */
spf_record spf;
parse_spf("\"v=spf1 include:_spf.google.com ip4:192.0.2.0/24 ~all\"", &spf);
printf("spf: valid=%d version=%s lookups=%zu mechanisms=%zu status=%s\n",
spf.valid, spf.version ? spf.version : "-", spf.lookup_count, spf.mechanism_count,
status_name(spf.valid ? status_from_warnings(spf.warning_count) : STATUS_FAIL));
print_warnings(spf.warnings, spf.warning_count);
/* DKIM: a 1024-bit RSA key — expect the "below recommended" warning. */
dkim_record dkim;
parse_dkim("v=DKIM1; k=rsa; s=email; h=sha256; "
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"
"wJaqNigTMVfPz8ZGfC6xc3vlzK9_LF9Ea_Y4O7dZ3uMLLpRy0iHw"
"q3HpLQs8TvDdZCFEfjHn1MEuXh_V0LiPjA1YqNW_2pkQxIN_4f8S"
"t9ExampleKeyTruncatedForDisplayPurposesOnly01234567"
"89abcdefghijk3wIDAQAB", &dkim);
printf("dkim: valid=%d key=%s bits=%ld hashes=%zu status=%s\n",
dkim.valid, dkim.key_type, dkim.key_bits, dkim.hash_count,
status_name(status_from_warnings(dkim.warning_count)));
print_warnings(dkim.warnings, dkim.warning_count);
/* DMARC: quarantine at 100% with aggregate reports — healthy. */
dmarc_record dmarc;
parse_dmarc("v=DMARC1; p=quarantine; rua=mailto:agg@example.com; adkim=s", &dmarc);
printf("dmarc: valid=%d pct=%ld rua=%zu adkim=%c status=%s\n",
dmarc.valid, dmarc.percent, dmarc.aggregate_count,
dmarc.dkim_alignment ? (char)dmarc.dkim_alignment : '-',
status_name(status_from_warnings(dmarc.warning_count)));
print_warnings(dmarc.warnings, dmarc.warning_count);
/* Domain normalization shared by all three checks. */
const char *inputs[] = {
"https://user@mail.example.com:8443/inbox?x=1", "Mailto:PERSON@Example.COM.",
};
for (size_t i = 0; i < 2; i++) {
char *host = normalize_domain(inputs[i]);
printf("normalize_domain(\"%s\") = \"%s\" domain-like=%d\n",
inputs[i], host, is_domain_like(host));
free(host);
}
spf_record_free(&spf);
dkim_record_free(&dkim);
dmarc_record_free(&dmarc);
return 0;
}
Also available in 9 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →