Tree
- Tree:
b232129e2554eea5935968e0f6a28ac48c7086bb- Date:
- Message:
- Add SEARCH SAVE; fix FETCH, queue, login timing, slow clients; man pages Change 1 of 6: Find where FETCH's modifiers begin by parsing, not by counting RFC 9051 section 9 lets a single message data item be sent without parentheses, and a HEADER.FIELDS item holds a space inside its brackets. RFC 4466 section 3 puts the optional fetch-modifiers after the item or list, separated by one SP. imapd looked for that boundary in split_trailing_modifiers(), which cut an unparenthesized item at its first space and counted parentheses in a list without skipping quoted strings. So "FETCH 1 BODY.PEEK[HEADER.FIELDS (Subject)]" was answered BAD, and so was any list naming a field such as "a)b", which RFC 5322 section 3.6.8 allows in a field name. fetch_dispatch() now takes the first token from fetch_att_tok(), the scanner parse_fetch_atts() already uses, which skips quoted strings and counts brackets and parentheses together. Whatever follows is the modifier list. split_trailing_modifiers() is gone. Change 2 of 6: Run queued commands after a refused literal, and keep both literals A command that arrives while another is in flight is queued, and imapd runs the queue in order, as RFC 9051 section 5.5 requires when commands could affect each other. A queued command whose non-synchronizing literal was still being gathered could be refused there: for a NUL, which RFC 9051 section 9's CHAR8 excludes, or for passing the 8191-octet command cap, which a correct client can reach with two 4096-octet literals. The listener then discarded the rest of that command, as RFC 7888 section 3 requires, but did not run the queue. Commands queued before the refused one waited for the next store reply, and a command sent after it ran first. If no later command used the store, they were never answered. The loop now runs the queue once a refused command's last line has been skipped, as it already did after a gathered command's last line. A queued command with two non-synchronizing literals also lost the text between them: the line after the first literal was copied into the command buffer without moving its length, so the second literal overwrote it, and "SEARCH TEXT {5+} ... TEXT {5+} ..." pipelined behind another command was answered BAD. The length now moves past the line. Change 3 of 6: Support SEARCH RETURN (SAVE) and the "$" marker RFC 9051 folds SEARCHRES (RFC 5182) into IMAP4rev2, so a client may send SEARCH RETURN (SAVE) and then use "$" wherever a sequence set goes, without asking for a capability. imapd refused SAVE with NO and "$" as an invalid sequence set. The store now keeps "$" for each session, as UID ranges. A SAVE search records its matches as it walks the index, one range per run of adjacent index lines, so the ranges stay exact: new messages get higher UIDs and an expunge only removes, as RFC 9051 section 6.4.4.1 requires. MIN and MAX without ALL or COUNT save only those messages (Table 4). SAVE alone sends no ESEARCH (section 6.4.4). A result of more than 500 runs is refused with NO [NOTSAVED], and "$" is emptied (section 6.4.4.3), as it is after any SAVE answered NO, including the listener's own NO for an unsupported CHARSET and a SAVE that gives up waiting for the index lock. A successful SELECT or EXAMINE empties it. "$" is accepted as the last element of a sequence set, as the grammar allows, so "1,$" works. FETCH, STORE, COPY, MOVE and UID EXPUNGE append the saved UIDs to the rest of the set, and answer NO [LIMIT] when the two together pass 500 ranges. SEARCH matches "$" by UID, so it costs one node however large it is. In a SELECT's QRESYNC known-uids, "$" is the value from before the SELECT. A "$" naming a message another session expunged is not refused as RFC 2180 section 4.1.2 allows for message numbers: "$" is kept by UID. Change 4 of 6: Make a failed login cost the same whether or not the account exists A failed AUTHENTICATE for a name in the credentials file paid for crypt_checkpass(3) at that entry's bcrypt cost. For a name with no entry, auth.c passed a NULL hash, which libc fakes at cost 8, while "encrypt -b a", as imapduser uses it, picks a cost for the machine; it picked 9 on the test host. A failure for a missing name was answered in about half the time (35.8 against 65.8 ms measured), which told a client which names exist. RFC 9051 section 11.7 asks that a failing login not say whether the user name is the invalid part. cred_lookup() now reads the whole credentials file on every lookup, keeps the first valid match, and reports the highest bcrypt cost among valid entries. A name with no valid entry is checked against a well-formed dummy hash at that cost. An entry hashed at a lower cost than the file's highest can still be told apart by timing. An over-long line in the credentials file now refuses every login, not only logins for names that come after it. Change 5 of 6: Let a slow client be slow, without stalling its account A client whose socket stayed full for 5 seconds was cut off, logged in or not: session_write() gave up after one poll(2) of 5 seconds without progress. While the listener slept in that poll it read nothing from its store channel, which blocked at the store's end, so the account's store child slept in sendmsg(2) once a FETCH had queued more than the socket held, and every other session of the account waited with it. On the test host a second session's NOOP took 5.05 s while one session stopped reading a FETCH. The timeout is now 5 seconds before login and 30 minutes after, the floor RFC 9051 section 5.4 sets for an inactivity autologout. Before login a longer sleep would outlast the login grace timer, which cannot fire while the listener sleeps. The store sets O_NONBLOCK on each listener channel as it attaches one, as keymgr already does, so a listener that stops reading fills only its own queue. A session whose write fails is torn down at once, not when the client next sends. Change 6 of 6: Split imapd.conf(5) out of imapd(8), and correct the man pages imapd.8 had grown to 992 lines, about half of them the configuration grammar under FILES. Every base daemon it was compared with keeps that in a section 5 page: smtpd.conf(5), httpd.conf(5), ntpd.conf(5), ldapd.conf(5), relayd.conf(5), sshd_config(5). The directives now live in imapd.conf.5, laid out as httpd.conf.5 is, with an EXAMPLES section. imapd.8 is cut to 295 lines on the shape of ldapd.8, with the credentials file in an AUTHENTICATION section. The rc.d install steps, the Makefile's relink check and the reasoning behind each limit are gone from the pages; the install steps were already in README.md. The rewrite also corrects what the old page said against the code. A missing /etc/imapd.conf is fatal, not a default configuration (parse.y pushfile(), main.c config_load()). "listen on" requires "port". The configuration file is refused when group-executable or accessible by others, not only when writable (check_file_secrecy()). The parent accepts connections and starts listener and auth workers for each one, and the process that is not restarted when it exits is keymgr, not the listener or auth process (parent.c reap_child()). The Makefile installs both pages, and imapd-teardown removes imapd.conf.5. imapduser.8 cross-references the new page. A second pass checked every remaining claim in imapd.8, imapd.conf.5 and imapduser.8 against the code and the RFC texts. Wrong, and now corrected: - The credentials file rule. The page asked for root, group _imapauth, mode 0640 or stricter, which allowed modes the auth process cannot read (0600, 0400) and forbade files it accepts. It now states what cred_file_secure() checks: owned by root or _imapauth, not group writable or executable, not accessible by others, and readable by _imapauth. 0640 is what imapduser sets. - A kept subscription was cited to RFC 9051 section 6.3.8 as a requirement. It is a SHOULD NOT in section 6.3.7; renaming is in 6.3.6. - The command list omitted NOOP and LOGOUT, and did not say that LOGIN is always refused. - SIGHUP: sessions already connected keep their settings; new ones get the reloaded values, since each listener worker is given them once. - attachment max also bounds SEARCH, so a BODY or TEXT search that reaches a larger message fails with NO. - imapduser rewrites the credentials file's ownership only when it creates the file or after -d, not on every write; -s is not ignored by -d; re-running imapduser -a does not fix the group. - smtpd.conf(5)'s directive is "smtp max-message-size". Added: the credentials file's line rules (comments, skipped lines, bcrypt only, uid and gid not 0, maildir relative without "." or "..", first valid line wins); the on-disk layout, with imapd.index and the lock and temporary files beside it; that a TLS key failing its check is not loaded; imapduser's username characters, id selection from 2000, and maildir mode. CAVEATS now lists RENAME INBOX refused, LIST return options and multiple patterns refused, FETCH BINARY items answered BAD, part-number HEADER, TEXT and MIME sections and the RFC822 items not returned, the response code each command gives an invalid UTF-8 name, and the reserved mailbox names. STANDARDS adds RFC 4616 and RFC 8314. imapduser.8's credentials line rendered with a space after each colon and now uses Ql.
| 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:** 0.2.0 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 *parser* child, spun up by a *store* child on demand and retired after a spell idle, does every parse of attacker-reachable content — `SEARCH`'s grammar and `FETCH`'s `ENVELOPE`/`BODYSTRUCTURE`/header-field parsing alike — confined to `pledge(2)` `"stdio recvfd"`: it opens nothing itself, reading each message over a descriptor the store child passes it already open; and a *store* child is forked per authenticated account, shared by all of that account's sessions, chroots into the mail spool, and drops privileges to that account'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.
- **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` (including `RETURN (SAVE)` and the `$` saved-result-set marker, [RFC 5182](https://www.rfc-editor.org/rfc/rfc5182), folded into RFC 9051), `APPEND`, `COPY`, `MOVE`, `EXPUNGE`, `UNSELECT`, `CLOSE`, `SUBSCRIBE`, `UNSUBSCRIBE` (including LIST's [RFC 9051](https://www.rfc-editor.org/rfc/rfc9051) section 6.3.9.1 `SUBSCRIBED` selection option), 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.
ACL/shared-mailbox support is 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/man5` and `/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 `imapd.conf(5)`.
## Creating the daemon accounts
imapd drops privileges to three accounts of its own and will not start until all three exist. Create them once, before the first mailbox account, since `imapduser(8)` gives the credentials file to the `_imapauth` group:
```
doas useradd -c "IMAP Daemon" -d /var/empty -s /sbin/nologin _imapd
doas useradd -c "IMAP Auth" -d /var/empty -s /sbin/nologin _imapauth
doas useradd -c "IMAP Key Manager" -d /var/empty -s /sbin/nologin _imapkey
```
Each runs in a group of its own name, which `useradd(8)` creates by default unless `/etc/usermgmt.conf` sets `group` otherwise.
## 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 keymgr process, which holds the TLS private key, exits unexpectedly, it is not restarted and new TLS handshakes fail. Recovery is `rcctl restart imapd`. See `imapd(8)`.
`SIGHUP` reloads `spool`, `append max`, `attachment max`, `account sessions`, `connections max`, `idle poll`, `lock timeout`, `login grace`, `startups`, and the TLS certificate/key without dropping connected sessions, `listen on` and `credentials` changes still require a restart. See `imapd.conf(5)`.
IPv6 is supported (`listen on ::` or `listen on *` for dual-stack) but not the default, see the `listen on` directive in `imapd.conf(5)`.
## 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)`, `imapd.conf(5)` and `imapduser(8)` are the authoritative technical reference.
