Tree
- Tree:
c5fbdbdfc6162860febb524d4666df430374cdd3- Date:
- Message:
- libtls fake-key invariant checks, a MODIFIED fix, a credentials-file permission tightening, and a dedup pass listener terminates TLS without holding the private key: it installs libtls's placeholder key via tls_config_use_fake_private_key() and an RSA_METHOD/EC_KEY_METHOD override that forwards every private-key operation to keymgr. Three properties of libtls internals make that work, and until now all three held by inspection only. Nothing in the daemon checked them, and nothing underneath does either, libtls skips SSL_CTX_check_private_key() whenever the fake key is in use. keymgr_assert_fake_key() now checks all three on every private-key operation, before anything is composed for keymgr: A the key object carries no private component. Under the fake key libtls builds it from X509_get_pubkey(), and a public-key decode never writes d or priv_key, so this is a NULL-pointer test rather than a value test. If it fires, libtls has begun loading real key material into the process that parses hostile TLS records, and the separation this mechanism exists for is not in effect. B the pubkey-hash tag on ex_data slot 0 is present. smtpd's ca.c treats an unset tag as an unrelated key belonging to some other part of the process and falls through to the real method. That is right for smtpd's dispatcher and wrong here: listener configures exactly one keypair, once, at spawn, does no client-certificate verification, and cannot reach these callbacks with an ephemeral ECDHE key, since only sign_sig is overridden and ECDH agreement uses a different method slot. With no legitimate unrelated-key case, an absent tag is the regression, so the three fall-through branches are gone. C the tag is a NUL-terminated string within KEYMGR_HASH_MAX bytes, checked before anything reads it as one. Not merely a consistency check: strlcpy(3) walks its source to the NUL to compute its return value, unbounded once the destination is full, so keymgr_forward_rsa()'s own length guard could only fire after an over-read had already happened. libtls stores a struct tls_config pointer in the adjacent ex_data slot 1, so a slot renumbering was all it would have taken to point that walk at a C struct. Each failure is fatalx(). listener is a per-connection worker, so a regression costs that connection rather than the daemon, and costs it before any key operation is performed or forwarded. New testing/keymgr_fakekey_test.c asserts the same three properties against the real installed libtls, so a libtls change is caught by running a test rather than by a handshake misbehaving in production. It generates its own RSA and EC self-signed certificates in process, installs the same engine override, and drives four real handshakes over a non-blocking socketpair: RSA and EC, TLS 1.2 and 1.3. Two faults are injectable, so each check is seen to fire rather than assumed to. "-f realkey" configures a genuine private key using public API alone and requires A and B to fire; "-f untermtag" places an unterminated tag against a guard page, where C rejects it safely and a forked child running strlcpy(3) on the same tag dies with SIGSEGV. RSA_PRIVDEC is not exercised, reaching it needs static-RSA key exchange, which TLS 1.3 does not have and the "secure" cipher selection excludes, and the program says so in its own output rather than implying the coverage. No new dependency surface. The checks use RSA_get0_d(), EC_KEY_get0_private_key() and the ex_data getters that listener.c already includes <openssl/rsa.h> and <openssl/ec.h> for, all public and exported, and add no libtls-internal declaration beyond the tls_config_use_fake_private_key() extern already in the file. README now states which OpenBSD this builds on: -current, not 7.9. Building on the most recent stable release is a goal for 1.0. The credentials-file permission check auth.c's cred_lookup() applies now also rejects a file that is group-executable or not owned by root or the process's own (post-chroot, post-setresuid) uid. It already refused a world-accessible or group-writable file; a credentials file left mode 0650, or owned by neither root nor _imapauth, was accepted without comment. cred_file_secure() replaces the inline check and mirrors parse.y's check_file_secrecy(), the policy imapd.conf itself is already held to. New testing/cred_file_perm_test.c (generated by testing/gen_cred_perm_test.py, same splice-and-verify shape as mailbox_name_test.c) drives cred_file_secure() over 14 synthetic (mode, owner) cases, no real files or root needed, and fails on exactly the three the old check missed before this fix, passing all fourteen after it. An incomplete RFC 7162 MODIFIED set is no longer sent. SS3.1.3 requires the set to list every message that failed the UNCHANGEDSINCE test, and SS3.1.3's own client guidance is that a client re-checks and retries what it finds there, so a message missing from the set is one the client believes was stored and will never revisit. Three paths could produce that. A failed realloc(3) in session_handle_store_modified() dropped entries silently, and if it failed on the first entry the count stayed 0, the MODIFIED block was skipped entirely, and the client was told "STORE completed". A failed malloc(3) of the response buffers dropped the response code, which per SS3.1.3 says the same thing. A formatter truncation logged a warning and sent the short list anyway. All three now set one sticky flag and answer NO [UNAVAILABLE], RFC 5530 SS3, marking it transient so a client retries rather than treating the STORE as rejected. That matches what every other variable-length list here already does on a failed grow: search_alloc_failed, copy_alloc_failed and qresync_alloc_failed all refuse rather than send a short answer, and store_ipc.c says why for VANISHED, "a dropped range would leave the client holding a phantom UID it can never be told about". store_do() also gains the defensive pre-command reset that search_dispatch() has and it lacked. New mboxname.c/mboxname.h hold the mailbox-name rules once. The listener's copy of store.c's syntax check had already drifted once, missing the rejection of "." / ".." and of the on-disk index filenames, and utf8.c exists because the UTF-8 half of the same rule drifted before that. The four reserved filenames move into that header too, since spelling them as literals on one side and constants on the other is how the drift happened; store_internal.h includes it, so the store side is unchanged. Both validators survive as separate entry points, the store does not trust the listener, and re-checking on the far side of the imsg boundary is the point, but what they check is now one function. mailbox_name_is_inbox() joins it, replacing a byte-identical one-liner on each side. The rest is duplication with no behaviour attached. hdr_next_field() (mime.c) walks one RFC 5322 header field, replacing the line-end, CRLF-vs-LF, blank-line and obs-fold logic written out twice; its tri-state return also makes explicit the difference between "header ended cleanly" and "ran out", which read_message_header_fields() previously encoded as two different breaks setting two different values twenty lines apart. seqset_position() (index.c) answers "does this command apply to this message?" for FETCH, STORE and COPY. append_range_token() (store_cmd.c) carries the token/comma/budget tail format_seq_list() and format_range_list() both spelled out. send_mbox_request() now composes CREATE/DELETE/RENAME/LIST/STATUS too, taking every listener-to-store request through one function, and grew a no-trailing-array fast path so a fixed-size request no longer mallocs a copy of itself. session_writef() (listener.c) carries the CRLF- preservation fixup session_reply() and session_untagged() shared. mbox_root_enter() (mbox_manage.c) opens CREATE/DELETE/RENAME/LIST. read_file_capped() (parent.c) carries the fgetc(3) probe that tells "a file exactly the buffer's size" from "there is more". setup_peer_send() absorbs its search-oracle twin. keymgr_try_reload() and index_lines_grow() are byte-identical extractions.
| README.md | commits | blame |
| contrib/ | |
| src/ | |
README.md
# OpenIMAPD
A from-scratch IMAP4rev2 ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051)) server for OpenBSD, written in C in the privilege-separated tradition of `smtpd(8)`, `httpd(8)`, and `ntpd(8)`. No third-party IMAP library.
**Status:** Pre-release, actively developed. Not a port. See [Getting the source](#getting-the-source) below for the repository.
## What it is
- **Privilege-separated**, `smtpd`-style, across six processes: a root *parent* reads configuration and binds the listening sockets; unprivileged *listener* and *auth* children handle the network and credential checks; *keymgr* holds the TLS private key and performs every private-key operation on request, so the process terminating TLS never has the key in its address space; a per-connection *search-oracle* parses the `SEARCH` grammar, the largest attacker-reachable parser in the daemon — in a process with no descriptors and no filesystem; and a *store* child is forked per authenticated session, chroots into the mail spool, and drops privileges to that session's own user before ever touching a message. `pledge(2)`, `unveil(2)`, and `chroot(2)` enforce these boundaries, not just convention: *parent* is the only process that can pass a file descriptor at all, and *search-oracle* runs on bare `stdio`.
- **Storage**: stock maildir format (`tmp/`/`new/`/`cur/`, atomic delivery via `rename(2)`), readable with `ls` and `grep`, and natively understood by `smtpd(8)`'s own `maildir` delivery action. IMAP's extra bookkeeping (UIDs, UIDVALIDITY, per-message mod-sequences, keywords) lives in a small, `flock(2)`-guarded, line-oriented index file per mailbox, plain colon-delimited text, not a database.
- **Transport**: STARTTLS on port 143 and implicit TLS on port 993 ([RFC 8314](https://www.rfc-editor.org/rfc/rfc8314)), via `libtls`. `AUTH=PLAIN` only, refused before TLS is established.
## Protocol coverage
`CAPABILITY`, `STARTTLS`, `AUTHENTICATE`, `ID`, `ENABLE`, `SELECT`, `EXAMINE`, `CREATE`, `DELETE`, `RENAME`, `LIST`, `LSUB`, `NAMESPACE`, `STATUS`, `FETCH` (including `ENVELOPE`, `BODYSTRUCTURE`, and MIME-part-addressed `BODY[<part>]`/`BODY.PEEK[<part>]`), `STORE`, `SEARCH`, `APPEND`, `COPY`, `MOVE`, `EXPUNGE`, `UNSELECT`, `CLOSE`, the `UID`-prefixed form of every command that supports it, `IDLE` (with the cross-session caveat noted below), and the [RFC 7162](https://www.rfc-editor.org/rfc/rfc7162) `CONDSTORE`/`QRESYNC` extensions.
`SUBSCRIBE`, `UNSUBSCRIBE`, and ACL/shared-mailbox support are deliberately left out.
**`IDLE` is a poll, not a kernel-driven push.** An `IDLE`ing session rechecks its selected mailbox every `idle poll` seconds (default 5, see `imapd.conf`), so new mail, whether delivered by an external MTA or by another IMAP session, is reported within one interval rather than instantly. Most polls are two `stat(2)` calls and no lock: the store child only re-reads the index and streams UIDs when the mailbox directory or `new/` has actually been touched. Setting `idle poll 0` disables polling entirely, which restores the earlier behaviour where an `IDLE`ing session saw nothing until it sent `DONE`.
## Requirements
OpenBSD only. This depends on `<imsg.h>`, `pledge(2)`, `unveil(2)`, and libutil's `imsgbuf_*` API, none of which exist outside OpenBSD. Links against libevent, libtls/libssl/libcrypto, and libutil, all base-system libraries (see `src/Makefile`).
**imapd requires OpenBSD -current, and will not build on 7.9.**. Building on the most recent stable release is a goal for 1.0.
`keymgr`, the process that isolates the TLS private key from `listener`, additionally depends on `tls_config_use_fake_private_key()` and undocumented ex_data-tagging behavior inside `tls_keypair_load()`, both unexported libtls/LibreSSL internals with no compatibility promise, and neither declared in libtls's public, installed `tls.h`.
## Building and installing
```
cd src
make
doas make install
```
Installs the daemon to `/usr/local/sbin/imapd`, man pages to `/usr/local/man/man8`, the `imapduser` account-provisioning tool alongside the daemon, and a sample config to `/usr/local/share/examples/imapd/imapd.conf`.
The `rc.d(8)` script is not installed automatically. `install(1)`, not `cp(1)`, so the installed copy is executable regardless of the source tree's own permission bits:
```
doas install -o root -g wheel -m 555 src/rc.d/imapd /etc/rc.d/imapd
```
## Configuring
Copy the sample config into place with restrictive permissions. imapd refuses to start against a config that's group- or world-writable, *or* world-readable:
```
doas install -o root -g wheel -m 600 \
/usr/local/share/examples/imapd/imapd.conf /etc/imapd.conf
```
Every directive is documented inline in the sample file; the full reference is in `imapd(8)`'s FILES section.
## Creating an account
imapd's users aren't real system accounts, `imapduser(8)` manages a bespoke credentials file (`username:passwordhash:uid:gid:maildir`, bcrypt via `crypt_checkpass(3)`) and the matching maildir ownership together, since no combination of `useradd(8)`/`userdel(8)` can safely keep both in sync:
```
doas imapduser -a someuser
```
See `imapduser(8)` for `-d` (revoke login without touching mail) and the `-c`/`-s`/`-u`/`-g` overrides.
## Running
```
doas rcctl enable imapd
doas rcctl start imapd
```
## Known limitations
Beyond the deliberate protocol-scope decisions covered in `imapd(8)`'s CAVEATS:
- If the listener or auth process exits unexpectedly after startup, it is not automatically restarted. Recovery is `rcctl restart imapd`. See `imapd(8)`.
`SIGHUP` reloads `spool`, `attachment max`, `idle poll`, `startups`, and the TLS certificate/key without dropping connected sessions, `listen on` and `credentials` changes still require a restart. See `imapd(8)`.
IPv6 is supported (`listen on ::` or `listen on *` for dual-stack) but not the default, see `imapd(8)`'s `listen on` directive.
## Getting the source
The repository is hosted with [Game of Trees](https://gameoftrees.org/) (`got`), read-only anonymous access over SSH:
```
got clone ssh://anonymous@got.openimapd.dev/imapd
```
The repository is also git-compatible; a plain `git clone` against the same URL works too:
```
git clone ssh://anonymous@got.openimapd.dev/imapd
```
## Security
Report security issues to security@openimapd.dev. General questions or feedback: feedback@openimapd.dev.
## License
ISC. See the copyright header in each source file.
## More
`imapd(8)` and `imapduser(8)` are the authoritative technical reference.
