commit a96b2723a3021df065f3860ba0115f2bfea97764 from: David Williams date: Thu Aug 27 20:36:37 2026 UTC Land post-refactor source tree (listener.c/store.c split into 17 files); auth sends uid/gid/maildir to parent directly instead of via listener; bump to 0.1.1 commit - d796c4b1f495b1727d8f9698d9e42374051e54de commit + a96b2723a3021df065f3860ba0115f2bfea97764 blob - 5d16137ef931b81fc220b56307c58534bb8579f3 blob + d52e35c2e1f4e621ef4078d51583e7add8cb3d06 --- README.md +++ README.md @@ -2,7 +2,7 @@ A from-scratch IMAP4rev2 ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051)) server for OpenBSD, written in traditional C in the privilege-separated tradition of `smtpd(8)`, `httpd(8)`, and `ntpd(8)`. No third-party IMAP library, no borrowed protocol engine. -**Status:** pre-release, version 0.1. Actively developed. Not yet a port — see [Getting the source](#getting-the-source) below for the repository. +**Status:** pre-release, version 0.1.1. Actively developed. Not yet a port — see [Getting the source](#getting-the-source) below for the repository. ## What it is blob - e03331f785bb81925fc872c7ba73aeb8050f1a40 (mode 755) blob + bfef11cc5d838831f3115309fc760b76d835c501 (mode 644) --- contrib/imapd-teardown +++ contrib/imapd-teardown @@ -2,6 +2,20 @@ # # $OpenIMAPD$ # +# Copyright (c) 2026 David Williams +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# # imapd-teardown -- completely remove an installed imapd, to test # repeated from-scratch installs. # @@ -9,8 +23,7 @@ # installed by src/Makefile's afterinstall: target -- there's no real # operational reason a production box would ever want to "uninstall # itself," so it stays contrib/-only, run straight out of the source -# tree). It exists because the fresh-install pass documented in -# README.skeleton ("Fresh imapd install on premio (task #228)") found +# tree). It exists because a fresh-install pass (task #228) found # five real gaps that only showed up by actually attempting an install # on a genuinely clean system -- this script is what makes "genuinely # clean system" repeatable without needing an actual fresh box every @@ -33,9 +46,8 @@ # # - smtpd.conf's shd_userbase table (a different daemon's config) is # never touched. If you also want smtpd to stop trying to deliver -# into a wiped spool, that's a separate, deliberate edit -- see -# the smtpd.conf gap in README.skeleton's fresh-install writeup -# for what that table looks like. +# into a wiped spool, that's a separate, deliberate edit to that +# table. # # Usage: # doas ./imapd-teardown [-y] [-M] [-c credentials-dir] [-f config-file] @@ -139,9 +151,8 @@ do_userdel() { else userdel "$_acct" 2>/dev/null || true # useradd's own default (no -g given) creates a same- - # named group alongside the account -- see README. - # skeleton's account-provisioning writeup, confirmed - # there against the pre-rename _openimap/_openimapd + # named group alongside the account, confirmed + # against the pre-rename _openimap/_openimapd # accounts. userdel(8) itself makes no mention of # touching groups, so that group is cleaned up # separately here, guarded against already being gone. blob - fb06375a9eb329bd597bd0e995475eb338ebe2b7 (mode 755) blob + 4a3837a3ad12eadd36f78dce37c7223f721d5187 (mode 644) --- contrib/imapduser +++ contrib/imapduser @@ -2,6 +2,20 @@ # # $OpenIMAPD$ # +# Copyright (c) 2026 David Williams +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# # imapduser -- add or delete an imapd mailbox account. # # Renamed and given a real -a/-d mode split from its previous identity @@ -16,15 +30,14 @@ # (github.com/openbsd/ports, security/cyrus-sasl2/pkg/PLIST, read # directly rather than assumed): it installs "@bin sbin/saslpasswd2" # and "@man man/man8/saslpasswd2.8" for precisely this reason -- -# managing sasldb2, SASL's own bespoke secrets store. See -# README.skeleton for the fuller writeup of this design decision. +# managing sasldb2, SASL's own bespoke secrets store. # # imapd's credentials file is deliberately self-contained the same # way: auth.c's cred_lookup() reads "username:passwordhash:uid:gid: # maildir" lines straight out of it and never calls getpwnam(3) for an -# IMAP end user (see auth.c's own header comment, citing -# openimap-privsep-design.md -- that decision is specifically about -# end users, not about auth's own _imapauth service identity). +# IMAP end user (see auth.c's own header comment -- that decision is +# specifically about end users, not about auth's own _imapauth service +# identity). # store.c's per-session store child then drops privileges directly to # that uid/gid (IMSG_STORE_INIT handling) before chroot(2)-ing into # spool_root and chdir(2)-ing into "/maildir" -- chdir(2) is NOT @@ -39,8 +52,7 @@ # pointed at. It does NOT create or remove a real system account: no # useradd(8)/userdel(8)/adduser(8), nothing written to /etc/passwd or # /etc/group. That's the whole point of the design this script is -# automating -- see the project's own README.skeleton for the fuller -# writeup. +# automating. # # Usage: # imapduser -a [-c credentials-file] [-s spool-root] [-u uid] [-g gid] username blob - 1e7591ee676c84c03570f9cffa7293aba421de20 blob + ac69fe628610f731e8f0c4d4be75f349932e41d4 --- contrib/imapduser.8 +++ contrib/imapduser.8 @@ -173,18 +173,16 @@ Default spool root; see .Nm was written for the OpenIMAPD project. It replaces an earlier, add-only, contrib/-only script named -.Nm newimapuser ; -see the project's own -.Pa README.skeleton -for the rationale behind the rename and the -.Fl a Ns / Ns Fl d -split, including the real precedent this follows: OpenBSD's own +.Nm newimapuser , +renamed and split into +.Fl a Ns / Ns Fl d . +.Nm +is now installed rather than left as a dev-tree-only script, following +the same real precedent as OpenBSD's own .Sy cyrus-sasl2 -port installs +port, which installs .Xr saslpasswd2 8 to .Pa ${PREFIX}/sbin -for the same reason -.Nm -is now installed rather than left as a dev-tree-only script -- -managing a daemon's own bespoke, non-system credentials store. +for the same reason: managing a daemon's own bespoke, non-system +credentials store. blob - 2e079fcefdb9b26541b8babb639326c61e0cb4fa blob + a2453cb647b3f6b3ce336c5fa0ada94d5d63bfd8 --- src/Makefile +++ src/Makefile @@ -16,9 +16,30 @@ # project fits the single-daemon shape, so the project itself was # renamed OpenIMAP -> OpenIMAPD to match. PROG= imapd -SRCS= main.c parent.c listener.c auth.c store.c log.c imsgev.c \ - parse.y +# listener.c and store.c were each a single file through the end of +# 0.1 (10,619 and 7,768 lines respectively) -- far larger than any file +# in smtpd (24 files, largest 3,104 lines) or httpd (largest 2,029 +# lines), the two base-system daemons this project otherwise follows +# most closely. Split by responsibility, matching that precedent: the +# listener process's own source is now listener.c (core: accept loop, +# session I/O, line dispatch) + auth_cmd.c + mailbox_cmd.c + append_cmd.c +# + fetch_cmd.c + search_cmd.c + store_cmd.c + store_ipc.c, sharing +# struct session and cross-file prototypes via listener.h (not installed, +# not part of the wire protocol -- see that file). The store process's +# own source is now store.c (core: imsg dispatch loop) + index.c + mime.c +# + envelope.c + mbox_fetch.c + mbox_search.c + mbox_store.c + +# mbox_manage.c + mbox_copy.c, sharing struct mbox_index and cross-file +# prototypes via store_internal.h the same way. Pure move, no behavior +# change -- see each new file's own header comment for exactly which +# commands/responsibility it holds. +SRCS= main.c parent.c log.c imsgev.c parse.y \ + listener.c auth_cmd.c mailbox_cmd.c append_cmd.c fetch_cmd.c \ + search_cmd.c store_cmd.c store_ipc.c \ + auth.c \ + store.c index.c mime.c envelope.c mbox_fetch.c mbox_search.c \ + mbox_store.c mbox_manage.c mbox_copy.c + # imapd isn't in OpenBSD base and has no ports-framework Makefile of its # own (no bsd.port.mk, no PREFIX), so BINDIR/MANDIR must be set explicitly: # neither bsd.own.mk nor bsd.prog.mk defaults BINDIR (confirmed by grepping @@ -62,7 +83,7 @@ MANDIR= /usr/local/man/man # literal text -- the later redirect wins for the same fd, so "> /dev/null" # silently drops and install.sh ends up with a bare "./${PROG} -V" instead # of the intended output-suppressed form. Confirmed by reading the actual -# generated line in a real "make install" transcript on premio: "-V"'s +# generated line in a real "make install" transcript on test hardware: "-V"'s # stdout leaked into rc.d/imapd's rc_pre() relink log instead of being # discarded. Harmless (doesn't affect correctness -- install.sh's # "set -o errexit" still aborts on real failures either way), but not the @@ -101,14 +122,13 @@ DPADD+= ${LIBTLS} ${LIBSSL} ${LIBCRYPTO} MAN= imapd.8 -WARNS= 6 -CFLAGS+= -Wall -Wstrict-prototypes -Wmissing-prototypes +CFLAGS+= -Wall -Wextra -Wstrict-prototypes -Wmissing-prototypes CFLAGS+= -Wmissing-declarations -Wshadow -Wpointer-arith -CFLAGS+= -Wsign-compare +CFLAGS+= -Wsign-compare -Wcast-qual -Wcast-align # Explicit -g: bsd.prog.mk's DEBUG?=-g default apparently isn't reaching # the actual compile line in this tree (the crash-diagnosis gdb session -# on premio showed "no debugging symbols found" against a plain `make` +# on the test machine showed "no debugging symbols found" against a plain `make` # build), so force it directly rather than relying on that default. DEBUG= -g blob - ea5d4f17b82c25482bae5d578d4e83b4a32d760e blob + 03591a478c2cae7027e77e94fad9b6e7472667f3 --- src/auth.c +++ src/auth.c @@ -14,32 +14,7 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* - * auth.c -- credential verification process. Implements the "auth" - * section of openimap-privsep-design.md: verifies AUTHENTICATE PLAIN - * credentials against the self-contained flat credential file - * ("username:passwordhash:uid:gid:maildir", bcrypt hashes), using - * crypt_checkpass(3) -- sourced against the local crypt_checkpass(3) man - * page, including its documented timing-mitigation behavior for unknown - * usernames (see auth_verify() below). - * - * API NAMES: checked against the real src/imsg.h this session -- see - * the header comment in parent.c for the full verification note. - * - * getpwnam("_imapauth") below is a DIFFERENT thing from the - * credential-file design decision in openimap-privsep-design.md ("the - * credential file is self-contained... auth never calls getpwnam()"). - * That decision was about IMAP end users (mailbox owners) not needing - * real system accounts. _imapauth is auth's own fixed daemon-user - * identity -- an ordinary OpenBSD system daemon user, expected to exist - * in /etc/passwd like _smtpd/_syslogd/etc. Resolving *that* via - * getpwnam() is unrelated to, and does not reopen, the earlier decision. - * (Renamed from _openimapd along with the rest of the daemon's own - * on-disk/system identity -- see imapd.h's header comment for the - * rename-scoping policy. listener.c's counterpart daemon user is - * _imapd, not _imapauth -- the two roles need distinct names since - * "imapd" alone is already taken by the primary/listener role.) - */ +/* auth.c -- credential verification process: AUTHENTICATE PLAIN against the flat cred file. */ #include @@ -66,6 +41,7 @@ struct cred_entry { }; static struct imsgev iev_listener; +static struct imsgev iev_parent; /* fd 3, alive for the process's lifetime -- task #321 */ static char cred_file_basename[256]; static int cred_lookup(const char *, const char *username, @@ -73,6 +49,7 @@ static int cred_lookup(const char *, const char *user static void auth_verify(struct imsg_auth_request *, struct imsg_auth_result *); static void auth_dispatch(int, short, void *); +static void auth_dispatch_parent(int, short, void *); __dead void auth_main(void) @@ -88,30 +65,9 @@ auth_main(void) if (imsgbuf_init(&ibuf3, 3) == -1) fatal("imsgbuf_init"); - imsgbuf_allow_fdpass(&ibuf3); /* receives the fd-passed - * IMSG_SETUP_PEER peer fd below -- see - * imsgev.c's imsgev_init() comment. */ + imsgbuf_allow_fdpass(&ibuf3); /* for the fd-passed IMSG_SETUP_PEER peer fd below */ - /* - * IMSG_AUTH_INIT must be the first message read -- same reasoning - * as store.c's IMSG_STORE_INIT: we need cred_file before we can - * even compute a chroot() target, let alone chroot into it. Closes - * the gap flagged in an earlier pass, where this process took a - * struct openimap_config * that main.c never actually populated - * for a re-exec'd child -- conf->cred_file was always an empty - * string. See imapd.h's imsg_auth_init comment. - */ - /* - * imsg_get() before imsgbuf_read() -- not just tidiness. See - * imsgev.c's setup_recv_one_peer() header comment for the real - * deadlock this ordering caused elsewhere (parent's IMSG_SETUP_PEER - * + IMSG_SETUP_DONE coalescing into one recvmsg() on a SOCK_STREAM - * socketpair). The same risk applies here in principle -- parent - * sends IMSG_AUTH_INIT then, later, this channel's IMSG_SETUP_PEER, - * with no synchronization forcing them into separate reads -- so - * this loop checks for an already-buffered message before ever - * issuing a real blocking read. - */ + /* IMSG_AUTH_INIT must be read first: cred_file is needed before chroot() can be computed */ for (;;) { if ((n = imsg_get(&ibuf3, &imsg)) == -1) fatal("imsg_get"); @@ -129,21 +85,21 @@ auth_main(void) fatalx("auth: bad IMSG_AUTH_INIT payload"); imsg_free(&imsg); + /* auth's own daemon-user identity; distinct from listener's _imapd. */ if ((pw = getpwnam("_imapauth")) == NULL) fatalx("getpwnam _imapauth: no such user " "(expected, not yet provisioned by an install script)"); - /* - * chroot into the directory *containing* the credential file, per - * the design doc -- not the file itself. cred_file_basename is - * kept for the unveil() call below (relative to the new root). - */ - (void)strlcpy(chrootdir, init.cred_file, sizeof(chrootdir)); + /* chroot into the dir containing the cred file, not the file itself; basename kept for unveil() */ + if (strlcpy(chrootdir, init.cred_file, sizeof(chrootdir)) >= + sizeof(chrootdir)) + fatalx("cred_file too long: %s", init.cred_file); if ((slash = strrchr(chrootdir, '/')) == NULL) fatalx("cred_file must be an absolute path: %s", init.cred_file); - (void)strlcpy(cred_file_basename, slash + 1, - sizeof(cred_file_basename)); + if (strlcpy(cred_file_basename, slash + 1, + sizeof(cred_file_basename)) >= sizeof(cred_file_basename)) + fatalx("cred_file basename too long: %s", init.cred_file); *slash = '\0'; if (chroot(chrootdir) == -1) @@ -156,22 +112,27 @@ auth_main(void) setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1) fatal("cannot drop privileges to _imapauth"); - /* boot-time handshake: one peer (listener), then SETUP_DONE+ack -- - * see imsgev.c's setup_recv_*() header comments. */ + /* boot-time handshake: one peer (listener), then SETUP_DONE+ack */ peer_fd = setup_recv_one_peer(&ibuf3); setup_recv_done_and_ack(&ibuf3); event_init(); imsgev_init(&iev_listener, peer_fd, auth_dispatch, NULL); - /* - * unveil() path is relative to the chroot above -- "/" + - * cred_file_basename, per the design doc's "unveil() restricted - * to the single credential-file path, read-only." - */ + /* task #321: reuses fd 3's populated ibuf3 -- a fresh imsgbuf_init() + * would drop buffered bytes. Kept alive post-boot (unlike before) + * so successful logins can IMSG_AUTH_CRED parent directly; mirrors + * listener.c's own boot -> iev_parent conversion. */ + imsgev_init_from_ibuf(&iev_parent, &ibuf3, auth_dispatch_parent, NULL); + + /* unveil() path is relative to the chroot above: "/" + basename. */ { char unveil_path[512]; + /* cred_file_basename[256] is already strlcpy(3)-truncation- + * checked above; "/" + up to 255 bytes can never approach + * this 512-byte buffer, so snprintf(3) here can't truncate -- + * the return value is explicitly discarded, not overlooked. */ (void)snprintf(unveil_path, sizeof(unveil_path), "/%s", cred_file_basename); if (unveil(unveil_path, "r") == -1) @@ -189,24 +150,7 @@ auth_main(void) fatalx("auth: exited event loop"); } -/* - * Real bug caught on the first real-hardware run (OpenBSD, not this - * sandbox): imsg_compose() only queues a message in this process's own - * userspace buffer -- it performs no I/O itself. Confirmed directly - * against imsg_init(3)'s own EXAMPLES section: "When the socket is - * ready for writing, queued messages are transmitted with - * imsgbuf_write()." imsgev_add() (imsgev.c) correctly arms EV_WRITE - * whenever imsgbuf_queuelen() is nonzero, but until this fix nothing - * ever handled that event -- every dispatch function in this codebase - * only ever checked "event & EV_READ". The observed symptom: a listener - * process pegged at 25+ minutes of CPU time while otherwise idle (caught - * via `ps`), because listener_dispatch_auth()'s own unconditional - * imsgev_add() at the end of every call kept re-arming EV_WRITE for a - * write that never happened, on a socket that's *always* writable -- - * the textbook busy-loop shape. Confirmed on auth's side via ktrace(1): - * an IMSG_AUTH_REQUEST send from listener produced zero syscalls here, - * because it never actually left listener's own queue. - */ +/* EV_WRITE must be handled: imsg_compose() only queues, imsgbuf_write() puts it on the wire */ static void auth_dispatch(int fd, short event, void *arg) { @@ -244,26 +188,45 @@ auth_dispatch(int fd, short event, void *arg) log_warnx("bad IMSG_AUTH_REQUEST"); break; } - /* F8 fix: imsg_get_data() guarantees payload size but - * not NUL termination; force it before these fields are - * used as C strings by auth_verify(). */ + /* imsg_get_data() guarantees size, not NUL termination -- force it */ req.username[sizeof(req.username) - 1] = '\0'; req.password[sizeof(req.password) - 1] = '\0'; memset(&res, 0, sizeof(res)); res.session_id = req.session_id; auth_verify(&req, &res); - /* explicit_bzero() the plaintext password out of our - * own stack copy as soon as we're done with it -- - * not itself sourced from any uploaded file this - * session, just good hygiene given the credential - * material involved. */ + /* scrub the plaintext password from our stack copy */ explicit_bzero(req.password, sizeof(req.password)); if (imsg_compose(&iev->ibuf, IMSG_AUTH_RESULT, 0, 0, -1, &res, sizeof(res)) == -1) log_warn("imsg_compose IMSG_AUTH_RESULT"); imsgev_add(iev); + + /* task #321: parent, not listener, is who actually + * spawns the store child -- send it uid/gid/maildir + * directly rather than trusting listener to relay + * what we just told it. */ + if (res.ok) { + struct imsg_auth_cred cred; + + memset(&cred, 0, sizeof(cred)); + cred.session_id = res.session_id; + cred.uid = res.uid; + cred.gid = res.gid; + /* cred.maildir and res.maildir are both sized + * AUTH_MAILDIR_MAX -- truncation is structurally + * impossible, so the return value is discarded + * deliberately, same as imsg_store_init's own + * maildir field elsewhere. */ + (void)strlcpy(cred.maildir, res.maildir, + sizeof(cred.maildir)); + if (imsg_compose(&iev_parent.ibuf, + IMSG_AUTH_CRED, 0, 0, -1, &cred, + sizeof(cred)) == -1) + log_warn("imsg_compose IMSG_AUTH_CRED"); + imsgev_add(&iev_parent); + } break; } default: @@ -273,42 +236,50 @@ auth_dispatch(int fd, short event, void *arg) } imsg_free(&imsg); } - /* - * Real bug caught on first real-hardware run, right after fixing - * the missing-EV_WRITE gap above: the imsgev_add(iev) inside the - * IMSG_AUTH_REQUEST case only re-arms when this call actually - * processed a message. Once EV_WRITE handling was added, this - * function could now be invoked for a pure EV_WRITE firing with - * nothing new to read -- the for loop above finds nothing, no case - * runs, and without this unconditional call the event lapses for - * good (imsgev_init() is plain EV_READ, not EV_PERSIST -- see - * imsgev.c). auth has exactly one registered event, so losing it - * empties event_dispatch()'s whole watch set, which returns and - * hits this file's own "auth: exited event loop" fatalx() -- - * exactly what happened live: IMSG_AUTH_RESULT successfully sent - * (the EV_WRITE fix working as intended), immediately followed by - * auth exiting because nothing re-armed its read side afterward. - * Matches the same unconditional-re-arm shape already used by - * listener_dispatch_auth()/listener_dispatch_parent()/ - * session_store_dispatch() (listener.c) and store_child_dispatch() - * (parent.c). - */ + /* unconditional re-arm: imsgev_init() is EV_READ not EV_PERSIST, so a pure EV_WRITE call would let it lapse */ imsgev_add(iev); (void)fd; } -/* - * Verifies req->password against the stored hash for req->username, and - * fills *res. Always calls crypt_checkpass() -- with hash == NULL on an - * unknown username -- rather than short-circuiting on a failed - * cred_lookup(), per crypt_checkpass(3)'s documented behavior: "If the - * hash is NULL, authentication will always fail, but a default amount of - * work is performed to simulate the hashing operation." Short-circuiting - * here would let login timing leak whether a username exists in the - * credential file -- exactly what that NULL-hash behavior exists to - * prevent. - */ +/* task #321: parent never sends auth anything post-boot, so this exists to flush queued IMSG_AUTH_CRED writes and notice if parent's end closes */ static void +auth_dispatch_parent(int fd, short event, void *arg) +{ + struct imsgev *iev = arg; + struct imsg imsg; + ssize_t n; + + if (event & EV_WRITE) { + if (imsgbuf_write(&iev->ibuf) == -1) + fatal("imsgbuf_write"); + } + + if (event & EV_READ) { + if ((n = imsgbuf_read(&iev->ibuf)) == -1) + fatal("imsgbuf_read"); + if (n == 0) { + log_warnx("parent closed channel"); + event_del(&iev->ev); + return; + } + } + + for (;;) { + if ((n = imsg_get(&iev->ibuf, &imsg)) == -1) + fatal("imsg_get"); + if (n == 0) + break; + + log_debug("auth_dispatch_parent: unhandled %d", + imsg_get_type(&imsg)); + imsg_free(&imsg); + } + imsgev_add(iev); + (void)fd; +} + +/* always calls crypt_checkpass() with hash == NULL on unknown username, to avoid timing leaks */ +static void auth_verify(struct imsg_auth_request *req, struct imsg_auth_result *res) { struct cred_entry ce; @@ -319,12 +290,12 @@ auth_verify(struct imsg_auth_request *req, struct imsg if (found) hash = ce.passwordhash; - if (crypt_checkpass(req->password, hash) == 0 && found) { + if (crypt_checkpass(req->password, hash) == 0 && found && + strlcpy(res->maildir, ce.maildir, sizeof(res->maildir)) < + sizeof(res->maildir)) { res->ok = 1; res->uid = ce.uid; res->gid = ce.gid; - (void)strlcpy(res->maildir, ce.maildir, - sizeof(res->maildir)); } else { res->ok = 0; } @@ -332,14 +303,7 @@ auth_verify(struct imsg_auth_request *req, struct imsg explicit_bzero(&ce, sizeof(ce)); } -/* - * Scans the credential file (relative to our chroot, so just its - * basename -- see auth_main()) line by line for "username", per the - * "username:passwordhash:uid:gid:maildir" format resolved in - * openimap-privsep-design.md. Linear scan -- fine for v1's expected - * credential-file size (personal-use scope, a handful of users); revisit - * only if that stops being true. - */ +/* linear scan of "username:passwordhash:uid:gid:maildir" lines -- fine for v1's small cred files */ static int cred_lookup(const char *path, const char *username, struct cred_entry *out) { @@ -376,10 +340,12 @@ cred_lookup(const char *path, const char *username, st if (strcmp(fields[0], username) != 0) continue; - (void)strlcpy(out->username, fields[0], - sizeof(out->username)); - (void)strlcpy(out->passwordhash, fields[1], - sizeof(out->passwordhash)); + /* skip rather than silently truncate a field, same as every other malformed-line case */ + if (strlcpy(out->username, fields[0], sizeof(out->username)) + >= sizeof(out->username) || + strlcpy(out->passwordhash, fields[1], + sizeof(out->passwordhash)) >= sizeof(out->passwordhash)) + continue; errno = 0; out->uid = (uid_t)strtoul(fields[2], &ep, 10); if (*ep != '\0' || errno != 0) @@ -387,7 +353,9 @@ cred_lookup(const char *path, const char *username, st out->gid = (gid_t)strtoul(fields[3], &ep, 10); if (*ep != '\0' || errno != 0) continue; - (void)strlcpy(out->maildir, fields[4], sizeof(out->maildir)); + if (strlcpy(out->maildir, fields[4], sizeof(out->maildir)) >= + sizeof(out->maildir)) + continue; found = 1; break; } blob - /dev/null blob + 0af51913734a344c0d44415b736ff248d1c5fd40 (mode 644) --- /dev/null +++ src/append_cmd.c @@ -0,0 +1,452 @@ +/* + * Copyright (c) 2026 David Williams + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +/* + * append_cmd.c -- APPEND: literal-driven message upload, and its + * asynchronous IMSG_MBOX_APPENDED completion handling. + */ + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imapd.h" +#include "log.h" +#include "listener.h" + +/* RFC 9051 SS9 date-time via sscanf(3); calendar validity (e.g. Feb 31) unchecked, timegm(3) normalizes it. */ +int +parse_date_time(const char *s, int64_t *out) +{ + struct tm tm; + char mon[4]; + int day, year, hh, mm, ss, zh, zm, i; + char zsign; + time_t t; + int64_t zoff; + + memset(&tm, 0, sizeof(tm)); + + if (sscanf(s, "%2d-%3s-%4d %2d:%2d:%2d %c%2d%2d", &day, mon, &year, + &hh, &mm, &ss, &zsign, &zh, &zm) != 9) + return (-1); + + if (day < 1 || day > 31 || hh < 0 || hh > 23 || mm < 0 || mm > 59 || + ss < 0 || ss > 60 || year < 1970 || + (zsign != '+' && zsign != '-')) + return (-1); + + for (i = 0; i < 12; i++) { + if (strcasecmp(mon, fetch_month_names[i]) == 0) + break; + } + if (i == 12) + return (-1); + + tm.tm_mday = day; + tm.tm_mon = i; + tm.tm_year = year - 1900; + tm.tm_hour = hh; + tm.tm_min = mm; + tm.tm_sec = ss; + + if ((t = timegm(&tm)) == (time_t)-1) + return (-1); + + zoff = (int64_t)zh * 3600 + (int64_t)zm * 60; + if (zsign == '-') + zoff = -zoff; + + *out = (int64_t)t - zoff; + return (0); +} + +/* Result struct for parse_append_args() -- avoids an unwieldy number of out-parameters. */ +struct append_parsed { + char mailbox[MBOX_NAME_MAX]; + uint32_t sysflags; + char keywords[MBOX_FLAGS_MAX]; + int has_date; + int64_t date; + uint64_t litlen; + int litnonsync; +}; + +/* RFC 9051 SS6.3.12 append grammar; flag-list parsing reused from parse_store_flags(). */ +int +parse_append_args(char *args, struct append_parsed *out, const char **errmsg) +{ + char *p = args; + + memset(out, 0, sizeof(*out)); + *errmsg = NULL; + + if (p == NULL || *p == '\0') { + *errmsg = "APPEND requires a mailbox name"; + return (-1); + } + + while (*p == ' ') + p++; + if (*p == '"') { + const char *start = p + 1; + char *end = strchr(start, '"'); + size_t len; + + if (end == NULL) { + *errmsg = "unterminated quoted mailbox name"; + return (-1); + } + len = (size_t)(end - start); + if (len == 0) { + *errmsg = "empty mailbox name"; + return (-1); + } + if (len >= sizeof(out->mailbox)) { + *errmsg = "mailbox name too long"; + return (-1); + } + memcpy(out->mailbox, start, len); + out->mailbox[len] = '\0'; + p = end + 1; + } else { + const char *start = p; + size_t len; + + while (*p != '\0' && *p != ' ') + p++; + len = (size_t)(p - start); + if (len == 0) { + *errmsg = "empty mailbox name"; + return (-1); + } + if (len >= sizeof(out->mailbox)) { + *errmsg = "mailbox name too long"; + return (-1); + } + memcpy(out->mailbox, start, len); + out->mailbox[len] = '\0'; + } + + while (*p == ' ') + p++; + if (*p == '(') { + char *start = p; + char *end = strchr(p, ')'); + char saved; + int rc; + const char *sub_err; + + if (end == NULL) { + *errmsg = "unterminated flag list"; + return (-1); + } + end++; /* include the ')' itself in the substring below */ + saved = *end; + *end = '\0'; + rc = parse_store_flags(start, &out->sysflags, out->keywords, + sizeof(out->keywords), &sub_err); + *end = saved; + if (rc != 0) { + *errmsg = sub_err; + return (rc); + } + p = end; + while (*p == ' ') + p++; + } + + if (*p == '"') { + const char *start = p + 1; + char *end = strchr(start, '"'); + size_t dlen; + char datebuf[64]; + + if (end == NULL) { + *errmsg = "unterminated date-time string"; + return (-1); + } + dlen = (size_t)(end - start); + if (dlen >= sizeof(datebuf)) { + *errmsg = "date-time string too long"; + return (-1); + } + memcpy(datebuf, start, dlen); + datebuf[dlen] = '\0'; + if (parse_date_time(datebuf, &out->date) == -1) { + *errmsg = "malformed date-time string"; + return (-1); + } + out->has_date = 1; + p = end + 1; + while (*p == ' ') + p++; + } + + if (*p != '{') { + *errmsg = "expected a message literal"; + return (-1); + } + { + const char *start = p + 1; + char *end = strchr(start, '}'); + char *digits_end; + const char *digits_stop; + char digitsbuf[24]; + size_t digits_len; + unsigned long long litlen; + + if (end == NULL) { + *errmsg = "malformed literal announcement"; + return (-1); + } + + out->litnonsync = (end > start && end[-1] == '+'); + digits_stop = out->litnonsync ? end - 1 : end; + digits_len = (size_t)(digits_stop - start); + + if (digits_len == 0 || digits_len >= sizeof(digitsbuf)) { + *errmsg = "malformed literal octet count"; + return (-1); + } + memcpy(digitsbuf, start, digits_len); + digitsbuf[digits_len] = '\0'; + + errno = 0; + litlen = strtoull(digitsbuf, &digits_end, 10); + if (*digits_end != '\0' || errno == ERANGE) { + *errmsg = "malformed literal octet count"; + return (-1); + } + out->litlen = (uint64_t)litlen; + + if (out->litnonsync && out->litlen > 4096) { + /* RFC 9051 SS4.3: non-sync literals capped at 4096 octets -- BAD, not cmd_append()'s NO size cap. */ + *errmsg = "non-synchronizing literal exceeds RFC " + "9051 SS4.3's 4096-octet limit -- use a " + "synchronizing literal instead"; + return (-1); + } + + if (end[1] != '\0') { + *errmsg = "literal must be the final argument"; + return (-1); + } + } + + return (0); +} + +/* Parses the literal announcement, allocates s->literal_buf, and enters literal-read mode (s->literal_pending). */ +int +cmd_append(struct session *s, const char *tag, char *args) +{ + struct append_parsed parsed; + int rc; + const char *errmsg; + + rc = parse_append_args(args, &parsed, &errmsg); + if (rc == -1) { + session_reply(s, tag, "BAD", errmsg); + return (1); + } + if (rc == -2) { + session_reply(s, tag, "NO", errmsg); + return (1); + } + + if (parsed.litlen > APPEND_LITERAL_MAX) { + /* RFC 5530 LIMIT code -- matches APPEND_LITERAL_MAX's situation precisely. */ + session_reply(s, tag, "NO", + "[LIMIT] message too large for this server (v1 size " + "limit -- see APPEND_LITERAL_MAX)"); + return (1); + } + + if (s->store_iev == NULL) { + /* Same store_iev invariant as cmd_select()/cmd_fetch() -- ST_AUTH requires it already wired. */ + log_warnx("session %u: APPEND with no store channel wired", + s->id); + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + + if (parsed.litlen > 0) { + if ((s->literal_buf = malloc((size_t)parsed.litlen)) == + NULL) { + log_warn("session %u: malloc APPEND literal buffer", + s->id); + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + } else + s->literal_buf = NULL; /* zero-length literal; session_dispatch_client() handles it without special-casing */ + + if (strlcpy(s->append_mailbox, parsed.mailbox, + sizeof(s->append_mailbox)) >= sizeof(s->append_mailbox) || + strlcpy(s->append_keywords, parsed.keywords, + sizeof(s->append_keywords)) >= sizeof(s->append_keywords)) { + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + s->append_sysflags = parsed.sysflags; + s->append_has_date = parsed.has_date; + s->append_date = parsed.date; + s->append_prev_state = s->state; + + /* tag is already IMAP_TAG_MAX-bounded by session_handle_line(); re-checked here defensively. */ + if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >= + sizeof(s->pending_tag)) { + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + s->literal_len = parsed.litlen; + s->literal_remaining = parsed.litlen; + s->literal_pending = 1; + + /* RFC 9051 SS4.3: only synchronizing literals need a "+" continuation; harmless but misleading to send for non-sync. */ + if (!parsed.litnonsync) + session_write(s, "+ Ready for literal data\r\n", 27); + + return (1); +} + +/* Builds the combined header+message imsg, enters SESSION_APPENDING; s->literal_buf is freed either way. */ +int +session_finish_append(struct session *s) +{ + struct imsg_mbox_append req; + char *combined; + size_t combined_len; + + memset(&req, 0, sizeof(req)); + if (strlcpy(req.mailbox, s->append_mailbox, sizeof(req.mailbox)) >= + sizeof(req.mailbox) || + strlcpy(req.keywords, s->append_keywords, sizeof(req.keywords)) >= + sizeof(req.keywords)) { + log_warnx("session %u: APPEND mailbox/keywords truncated -- " + "can't happen (both already bounded when first stored)", + s->id); + session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error"); + free(s->literal_buf); + s->literal_buf = NULL; + s->state = s->append_prev_state; + return (1); + } + req.sysflags = s->append_sysflags; + req.has_date = s->append_has_date; + req.date = s->append_date; + req.msglen = (uint32_t)s->literal_len; + + if (s->store_iev == NULL) { + log_warnx("session %u: APPEND with no store channel wired " + "(literal already read)", s->id); + session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error"); + free(s->literal_buf); + s->literal_buf = NULL; + s->state = s->append_prev_state; + return (1); + } + + combined_len = sizeof(req) + (size_t)s->literal_len; + if ((combined = malloc(combined_len)) == NULL) { + log_warn("session %u: malloc APPEND imsg buffer", s->id); + session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error"); + free(s->literal_buf); + s->literal_buf = NULL; + s->state = s->append_prev_state; + return (1); + } + memcpy(combined, &req, sizeof(req)); + if (s->literal_len > 0) + memcpy(combined + sizeof(req), s->literal_buf, + (size_t)s->literal_len); + + free(s->literal_buf); + s->literal_buf = NULL; + + s->state = SESSION_APPENDING; + + if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_APPEND, 0, 0, -1, + combined, combined_len) == -1) + log_warn("session %u: imsg_compose IMSG_MBOX_APPEND", s->id); + free(combined); + imsgev_add(s->store_iev); + + return (1); +} + +/* Terminal APPEND reply; restores s->state to s->append_prev_state (AUTHENTICATED or SELECTED). */ +void +session_handle_mbox_appended(struct session *s, + const struct imsg_mbox_appended *res) +{ + int appended_to_selected; + + s->state = s->append_prev_state; + + if (res->error != MBOX_OP_OK) { + if (res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX) + session_reply(s, s->pending_tag, "NO", + "[TRYCREATE] no such mailbox"); /* SS6.3.12: reports why, not a promise CREATE would help (v1 has none) */ + else + session_reply(s, s->pending_tag, "NO", + "APPEND failed"); + return; + } + + /* INBOX compared case-insensitively (SS5.1); any other mailbox name case-sensitively. */ + if (listener_mailbox_name_is_inbox(s->append_mailbox) && + listener_mailbox_name_is_inbox(s->selected_mailbox)) + appended_to_selected = 1; + else + appended_to_selected = + (strcmp(s->append_mailbox, s->selected_mailbox) == 0); + + /* RFC 9051 SS6.3.13: APPEND always adds one message -- unlike EXPUNGE/CLOSE, no res->count gate needed. */ + session_notify_idle_peers(s); + + if (s->append_prev_state == SESSION_SELECTED && appended_to_selected) { + char buf[32]; + + snprintf(buf, sizeof(buf), "%u EXISTS", res->exists); + session_untagged(s, buf); + } + + { + char buf[96]; + + snprintf(buf, sizeof(buf), + "[APPENDUID %u %u] APPEND completed", res->uidvalidity, + res->uid); + session_reply(s, s->pending_tag, "OK", buf); + } +} blob - 6b83b5da1105daeb3c5176264e6566c42ff04ba3 blob + 2ce9b6536055d078f1333f243a394898dd6243aa --- src/imapd.8 +++ src/imapd.8 @@ -466,6 +466,17 @@ refused with and a syntactically invalid destination name is refused outright .Pq Li BAD with no attempt to look it up. +.Li DELETE +of a mailbox that does not exist, and +.Li RENAME +with a source that does not exist, are refused with +.Pq Li NONEXISTENT ; +.Li CREATE +of a mailbox that already exists, and +.Li RENAME +to a destination that already exists, are refused with +.Pq Li ALREADYEXISTS +(RFC 5530). If a mailbox is renamed or deleted while a .Em different session blob - /dev/null blob + 4a48404e352ed638dd3f5cf8012c9b7d2326dd79 (mode 644) --- /dev/null +++ src/auth_cmd.c @@ -0,0 +1,403 @@ +/* + * Copyright (c) 2026 David Williams + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +/* + * auth_cmd.c -- CAPABILITY/NOOP/LOGOUT/ID/LOGIN/STARTTLS/ + * AUTHENTICATE/ENABLE: command-any and command-nonauth handlers that + * don't need an established mailbox session. + */ + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imapd.h" +#include "log.h" +#include "listener.h" + +/* + * RFC 9051 SS6.1.1 capability strings, selected by session->tls_active; + * CONDSTORE/QRESYNC (RFC 7162) advertised regardless of TLS. LOGINDISABLED + * is advertised in both strings -- cmd_login() below refuses LOGIN + * unconditionally, pre- and post-TLS, so both capability strings should + * say so rather than implying LOGIN might work once TLS is up. + */ +#define CAPABILITY_PRE_TLS "IMAP4rev2 STARTTLS LOGINDISABLED ID CONDSTORE QRESYNC" +#define CAPABILITY_POST_TLS "IMAP4rev2 AUTH=PLAIN LOGINDISABLED ID CONDSTORE QRESYNC" + + +int +cmd_capability(struct session *s, const char *tag, char *args) +{ + (void)args; /* RFC 9051: "Arguments: none" -- extra args ignored, not rejected */ + + session_untagged(s, s->tls_active ? + "CAPABILITY " CAPABILITY_POST_TLS : "CAPABILITY " CAPABILITY_PRE_TLS); + session_reply(s, tag, "OK", "CAPABILITY completed"); + return (1); +} + + +int +cmd_noop(struct session *s, const char *tag, char *args) +{ + (void)args; + session_reply(s, tag, "OK", "NOOP completed"); + return (1); +} + + +int +cmd_logout(struct session *s, const char *tag, char *args) +{ + (void)args; + + /* Exact example text from RFC 9051 SS6.1.3. */ + session_untagged(s, "BYE IMAP4rev2 Server logging out"); + session_reply(s, tag, "OK", "LOGOUT completed"); + session_teardown(s); + return (0); +} + + +int +cmd_id(struct session *s, const char *tag, char *args) +{ + /* RFC 2971 SS3.1: field/value list not parsed, just logged and discarded; always replies NIL per SS3.2 */ + log_debug("session %u: ID params: %s", s->id, + args != NULL ? args : "(none)"); + session_untagged(s, "ID NIL"); + session_reply(s, tag, "OK", "ID completed"); + return (1); +} + + +/* + * LOGIN is permanently disabled, matching LOGINDISABLED in both + * CAPABILITY strings above. AUTHENTICATE PLAIN (SASL) is the only + * supported credential path -- one fewer parser/credential-handling + * code path than supporting both, per this project's smaller-feature- + * set-is-smaller-attack-surface design principle. (A full LOGIN + * implementation was written and real-hardware-verified during Canary + * Mail interop testing, since Canary sends LOGIN, never AUTHENTICATE + * PLAIN; every other client tested -- Apple Mail, Airmail -- uses + * AUTHENTICATE PLAIN without issue, so LOGIN was re-disabled rather + * than kept enabled solely for one client's benefit.) + */ +int +cmd_login(struct session *s, const char *tag, char *args) +{ + (void)args; + + session_reply(s, tag, "NO", "LOGIN not supported, use AUTHENTICATE PLAIN"); + return (1); +} + + +int +cmd_starttls(struct session *s, const char *tag, char *args) +{ + if (args != NULL) { + /* RFC 9051 SS6.2.1 Result: "BAD - ... arguments invalid". */ + session_reply(s, tag, "BAD", "STARTTLS takes no arguments"); + return (1); + } + if (s->tls_active) { + /* RFC 9051 SS6.2.1: BAD if STARTTLS received after negotiation */ + session_reply(s, tag, "BAD", "TLS already active"); + return (1); + } + if (listener_tls_ctx == NULL) { + /* RFC 9051 SS6.2.1 NO + RFC 5530 UNAVAILABLE: cert/key loading failed at boot */ + session_reply(s, tag, "NO", + "[UNAVAILABLE] TLS negotiation unavailable"); + return (1); + } + + session_reply(s, tag, "OK", "Begin TLS negotiation now"); /* must precede TLS start, so goes out in cleartext */ + + s->inbuflen = 0; /* command-injection mitigation: discard plaintext already buffered past this line */ + + session_tls_start(s); + return (1); +} + +/* RFC 4616 SS2: authzid/authcid/passwd each up to 255 octets + 2 NUL delimiters = 767, rounded up */ +#define SASL_PLAIN_MAX 768 + +/* decodes+verifies one SASL PLAIN message (RFC 4616 SS2), sends IMSG_AUTH_REQUEST; never tears down the session, always returns 1 */ +int +sasl_plain_finish(struct session *s, const char *tag, const char *b64, + int allow_empty_equals) +{ + unsigned char raw[SASL_PLAIN_MAX]; + unsigned char *authcid, *nul; + const unsigned char *passwd; + int rawlen; + size_t off, authcidlen, passwdlen; + struct imsg_auth_request req; + + if (allow_empty_equals && strcmp(b64, "=") == 0) { + rawlen = 0; + } else { + rawlen = b64_pton(b64, raw, sizeof(raw)); + if (rawlen < 0) { + session_reply(s, tag, "BAD", "invalid base64"); + return (1); + } + } + + /* + * rawlen==0 (the allow_empty_equals "=" case just above) would end + * up here anyway -- memchr(3) can't find a NUL in a zero-length + * buffer -- but reading raw's still-uninitialized stack contents + * through memchr(3) to get there is needless even though every + * real memchr(3) treats n==0 as a no-op that never dereferences + * the pointer. Short-circuit it explicitly instead of relying on + * that (cppcheck flagged this as a genuine uninitialized-variable + * read, which is technically correct about raw's contents even + * though the call itself is harmless). + */ + if (rawlen == 0) { + session_reply(s, tag, "BAD", "malformed SASL PLAIN message"); + explicit_bzero(raw, sizeof(raw)); + return (1); + } + + nul = memchr(raw, '\0', (size_t)rawlen); + if (nul == NULL) { + session_reply(s, tag, "BAD", "malformed SASL PLAIN message"); + explicit_bzero(raw, sizeof(raw)); + return (1); + } + off = (size_t)(nul - raw) + 1; + authcid = raw + off; + nul = memchr(authcid, '\0', (size_t)rawlen - off); + if (nul == NULL) { + session_reply(s, tag, "BAD", "malformed SASL PLAIN message"); + explicit_bzero(raw, sizeof(raw)); + return (1); + } + authcidlen = (size_t)(nul - authcid); + passwd = nul + 1; + passwdlen = (size_t)rawlen - off - authcidlen - 1; + + /* RFC 4616 SS2: empty prep result SHALL fail verification -- NO not BAD, framing is fine */ + if (authcidlen == 0 || passwdlen == 0) { + session_reply(s, tag, "NO", "[AUTHENTICATIONFAILED] authentication failed"); + explicit_bzero(raw, sizeof(raw)); + return (1); + } + /* too big for imsg_auth_request's fixed fields; same generic NO, avoids a distinct error leaking an oracle */ + if (authcidlen >= AUTH_USERNAME_MAX || passwdlen >= AUTH_PASSWORD_MAX) { + session_reply(s, tag, "NO", "[AUTHENTICATIONFAILED] authentication failed"); + explicit_bzero(raw, sizeof(raw)); + return (1); + } + + memset(&req, 0, sizeof(req)); + req.session_id = s->id; + memcpy(req.username, authcid, authcidlen); + memcpy(req.password, passwd, passwdlen); + explicit_bzero(raw, sizeof(raw)); + + if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >= + sizeof(s->pending_tag)) { + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + s->state = SESSION_AUTHENTICATING; + + if (imsg_compose(&iev_auth.ibuf, IMSG_AUTH_REQUEST, 0, 0, -1, + &req, sizeof(req)) == -1) + log_warn("session %u: imsg_compose IMSG_AUTH_REQUEST", s->id); + imsgev_add(&iev_auth); + /* imsg_compose() already copied req, safe to scrub our stack copy */ + explicit_bzero(&req, sizeof(req)); + + return (1); +} + +/* client's response to our "+ " continuation after bare "AUTHENTICATE PLAIN" (see cmd_authenticate()) */ +int +session_handle_auth_continuation(struct session *s, const char *line) +{ + s->auth_cont = 0; /* next line is back to an ordinary tagged command regardless of outcome */ + + if (strcmp(line, "*") == 0) { /* RFC 9051 SS6.2.2: lone "*" cancels the exchange */ + session_reply(s, s->pending_tag, "BAD", + "AUTHENTICATE cancelled"); + return (1); + } + + return sasl_plain_finish(s, s->pending_tag, line, 0); +} + +/* client's response to our "+ idling" continuation (RFC 9051 SS6.3.13; see cmd_idle()); only "DONE" terminates IDLE */ +int +session_handle_idle_continuation(struct session *s, const char *line) +{ + s->idling = 0; + + if (strcasecmp(line, "DONE") != 0) { + session_reply(s, s->pending_tag, "BAD", + "expected DONE"); + return (1); + } + + session_reply(s, s->pending_tag, "OK", "IDLE terminated"); + return (1); +} + + +int +cmd_authenticate(struct session *s, const char *tag, char *args) +{ + char *mech, *p; + const char *initial; + + if (args == NULL) { + /* RFC 9051 SS6.2.2 Result: "BAD - ... arguments invalid". */ + session_reply(s, tag, "BAD", "Missing SASL mechanism name"); + return (1); + } + mech = args; + for (p = mech; *p != '\0' && *p != ' '; p++) + continue; + if (*p == '\0') { + initial = NULL; + } else { + *p++ = '\0'; + while (*p == ' ') + p++; + initial = (*p != '\0') ? p : NULL; + } + + /* RFC 9051 SS6.2.2: MUST NOT permit plaintext mechanisms pre-TLS */ + if (!s->tls_active) { + /* RFC 5530 PRIVACYREQUIRED: retry after STARTTLS */ + session_reply(s, tag, "NO", + "[PRIVACYREQUIRED] plaintext authentication requires TLS"); + return (1); + } + + /* v1 only implements PLAIN, matching CAPABILITY_POST_TLS */ + if (strcasecmp(mech, "PLAIN") != 0) { + session_reply(s, tag, "NO", + "authentication mechanism not available"); + return (1); + } + + if (initial != NULL) { /* RFC 9051 SS6.2.2 initial-resp: finishes in one round trip */ + return sasl_plain_finish(s, tag, initial, 1); + } + + /* no initial response: send "+", auth_cont routes the reply line to session_handle_auth_continuation() */ + if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >= + sizeof(s->pending_tag)) { + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + s->auth_cont = 1; + session_write(s, "+ \r\n", 4); + return (1); +} + +/* shared reply for a recognized command store.c can't run yet (no wire payload designed); NO not BAD, syntax is fine */ +int +stub_not_implemented(struct session *s, const char *tag, const char *cmdname) +{ + log_debug("session %u: %s not implemented (store.c's IMSG_MBOX_* " + "wire protocol isn't designed yet)", s->id, cmdname); + session_reply(s, tag, "NO", "not implemented"); + return (1); +} + +/* RFC 9051 SS6.3.1 ENABLE: unknown extensions ignored; ENABLED lists only what THIS command newly enabled, even if empty */ +int +cmd_enable(struct session *s, const char *tag, char *args) +{ + char buf[64]; + char *tok, *save; + int newly_condstore = 0, newly_qresync = 0; + + if (args == NULL) { + session_reply(s, tag, "BAD", + "ENABLE requires at least one capability argument"); + return (1); + } + + for (tok = strtok_r(args, " ", &save); tok != NULL; + tok = strtok_r(NULL, " ", &save)) { + if (strcasecmp(tok, "QRESYNC") == 0) { + if (!s->qresync_enabled) { + s->qresync_enabled = 1; + newly_qresync = 1; + if (!s->condstore_enabled) + newly_condstore = 1; + } + } else if (strcasecmp(tok, "CONDSTORE") == 0) { + if (!s->condstore_enabled) + newly_condstore = 1; + } + /* anything else: unadvertised extension, SS6.3.1 says ignore */ + } + + if (newly_condstore || newly_qresync) + session_condstore_enable(s); + + /* + * buf[64] can never truncate here: the only strings ever appended + * are these two fixed literals plus one separator space, 18 bytes + * total in the worst case ("QRESYNC CONDSTORE") -- but the return + * value is still explicitly discarded rather than silently ignored, + * per this project's check-every-return-value standard. + */ + buf[0] = '\0'; + if (newly_qresync) + (void)strlcat(buf, "QRESYNC", sizeof(buf)); + if (newly_condstore) { + if (buf[0] != '\0') + (void)strlcat(buf, " ", sizeof(buf)); + (void)strlcat(buf, "CONDSTORE", sizeof(buf)); + } + + if (buf[0] != '\0') { + char untagged[80]; + + snprintf(untagged, sizeof(untagged), "ENABLED %s", buf); + session_untagged(s, untagged); + } else + session_untagged(s, "ENABLED"); + + session_reply(s, tag, "OK", "ENABLE completed"); + return (1); +} blob - ac68f76cf15a2f085d826daffe7acd99af15e8b1 blob + 88870413145be405657cc25906d29343f8a98aea --- src/imapd.h +++ src/imapd.h @@ -16,11 +16,9 @@ /* * Shared definitions for all four imapd(8) process roles: parent, - * listener, auth, store. See ../openimap-privsep-design.md for the design - * this header implements -- process split, imsg message catalog, pledge - * strings, and the fork-per-session store mechanism are all decided there, - * not here. This header should not drift from that document; if it does, - * one of the two is wrong. + * listener, auth, store -- the process split, imsg message catalog, + * pledge strings, and the fork-per-session store mechanism are all + * defined here. * * struct/enum names below still say "openimap" in places (struct * openimap_config, enum openimap_proc_type) even after the imapd(8) @@ -40,7 +38,7 @@ * OpenSSH is the one exception, and only because it ships a whole * toolkit (ssh/scp/sftp/ssh-keygen/...), not a single daemon. This * project is shaped like the single-daemon case, so "OpenIMAP" was - * the wrong analogy; see docs/openimap.md and README.skeleton. Only + * the wrong analogy. Only * the installed daemon's own identity (PROG, man page, rc.d script, * default file paths), this header's own filename, and its include * guard/version macro (below) changed as part of that *earlier* @@ -65,7 +63,7 @@ * command has stable, greppable output if that's ever wanted. Bump by hand * until something better (git describe, etc.) is worth wiring in. */ -#define IMAPD_VERSION "0.1" +#define IMAPD_VERSION "0.1.1" /* * Process roles, selected at exec time via "-x ". See main.c. @@ -78,11 +76,9 @@ enum openimap_proc_type { }; /* - * imsg message catalog. Mirrors the table in openimap-privsep-design.md - * ("imsg message catalog (draft)") -- keep in sync with that document. - * IMSG_SETUP_PEER / IMSG_SETUP_DONE are the boot-time handshake (also - * reused, per that document, for the per-session store peer-wiring - * handshake after IMSG_STORE_INIT). + * imsg message catalog. IMSG_SETUP_PEER / IMSG_SETUP_DONE are the + * boot-time handshake, also reused for the per-session store peer-wiring + * handshake after IMSG_STORE_INIT. */ enum imsg_type { IMSG_NONE, @@ -110,7 +106,15 @@ enum imsg_type { IMSG_AUTH_REQUEST, IMSG_AUTH_RESULT, - /* per-session store spawn (listener -> parent -> new store child) */ + /* auth -> parent, per successful login (task #321: parent's + * privilege decision is sourced directly from auth, keyed by + * session_id, instead of being relayed through the network-facing + * listener process) */ + IMSG_AUTH_CRED, + + /* per-session store spawn: auth's IMSG_AUTH_CRED triggers the spawn + * in parent; IMSG_STORE_FORK is now parent -> listener only, used + * solely as a failure notification when the spawn couldn't proceed */ IMSG_STORE_FORK, IMSG_STORE_INIT, IMSG_STORE_PEER, @@ -206,9 +210,9 @@ enum imsg_type { /* * RFC 9051 SS6.3.4-SS6.3.6 (CREATE/DELETE/RENAME) and SS6.3.9 (LIST), - * this pass -- flat (non-nested) multi-mailbox support, per the - * design resolved in docs/openimap-storage-backend.md's "Open items" - * #10. IMSG_MBOX_CREATE/IMSG_MBOX_DELETE/IMSG_MBOX_RENAME and + * this pass -- flat (non-nested) multi-mailbox support, mailboxes as + * sibling subdirectories of the session's own per-user maildir root. + * IMSG_MBOX_CREATE/IMSG_MBOX_DELETE/IMSG_MBOX_RENAME and * IMSG_MBOX_LIST itself were already reserved in this enum from an * earlier skeleton pass (store_dispatch()'s "TODO: none of these * payload shapes are designed yet" case) -- only their payload @@ -241,13 +245,14 @@ enum imsg_type { }; /* - * Standard privsep imsg-over-event(3) wrapper. Not itself quoted from any - * uploaded source file this session -- this is a widely-used pattern in - * OpenBSD privsep daemons (smtpd.c's own use of event_dispatch(3)/ - * evtimer_set(3)/signal_add(3), observed directly this session, is what - * grounds using libevent here at all; the imsgev wrapper struct itself is - * this project's own plumbing on top of that, not copied from a specific - * quoted definition). + * Standard privsep imsg-over-event(3) wrapper. This struct's fields and + * imsgev.c's functions are this project's own implementation, not copied + * from a specific quoted definition -- but the name "imsgev"/"struct + * imsgev" for an imsgbuf+event(3) wrapper matches Eric Faurot's + * usr.sbin/ldapd/imsgev.c in OpenBSD base closely enough (not a generic + * name) that his copyright is carried in imsgev.c's own header as + * attribution for that naming/conceptual lineage, even though the actual + * function signatures and dispatch design here differ from his. */ struct imsgev { struct imsgbuf ibuf; @@ -289,11 +294,10 @@ struct openimap_config { uint16_t port_cleartext; /* 143, STARTTLS */ uint16_t port_implicit_tls; /* 993, RFC 8314 */ char spool_root[1024]; /* mail spool root, store's chroot */ - char cred_file[1024]; /* auth's credential file, see - * openimap-privsep-design.md's - * "auth" section for the + char cred_file[1024]; /* auth's credential file -- one + * line per user, format * username:passwordhash:uid:gid: - * maildir format */ + * maildir */ char tls_cert_file[1024]; char tls_key_file[1024]; uint32_t bodystructure_read_max; /* "attachment max" directive -- @@ -320,9 +324,9 @@ struct openimap_config { /* * Boot-time config-delivery payloads, closing the gap flagged in an * earlier pass: listener/auth don't read imapd.conf themselves (kept - * off their rpath/unveil surface deliberately -- see each role's pledge - * discussion in openimap-privsep-design.md), but nothing ever specified - * how they'd get their slice of it otherwise. Modeled directly on + * off their rpath/unveil surface deliberately, per each role's own + * pledge(2) string below), but nothing ever specified how they'd get + * their slice of it otherwise. Modeled directly on * IMSG_STORE_INIT's existing precedent: parent, which alone reads the * real config, hands over only the fields that role actually needs, not * the whole struct openimap_config. @@ -370,23 +374,34 @@ struct imsg_auth_result { }; /* - * openimap-privsep-design.md's credential-file field (line ~501): "maildir: - * path relative to the spool root store is chroot'd into, so store never - * needs an absolute-path credential field to escape its chroot." The design - * doc's imsg catalog already specified IMSG_STORE_FORK should carry "session - * id, resolved uid/gid, mailbox identifier" -- this field was the "mailbox - * identifier" from day one, it just never actually got added to either - * struct below, so auth.c's imsg_auth_result.maildir (correctly resolved - * per-user from the credential file) was being silently dropped on the - * floor by listener.c's session_request_store() before this pass. + * task #321: sent auth -> parent directly, over auth's own existing + * channel (the one IMSG_AUTH_INIT already arrives on), immediately after + * a successful login. This is the sole source parent uses to spawn a + * session's store child -- see parent_handle_store_fork() in parent.c. + * Previously this data (uid/gid/maildir, resolved by auth.c from the + * credential file) was relayed to parent by listener.c instead, meaning + * parent's privilege decision ultimately trusted a value repeated by the + * network-facing process rather than the process that actually verified + * the login. See docs/SECURITY-PATCHES.md's F1 writeup for the original + * finding and the recommended complete fix this implements. */ +struct imsg_auth_cred { + uint32_t session_id; + uid_t uid; + gid_t gid; + char maildir[AUTH_MAILDIR_MAX]; +}; + +/* + * task #321: parent -> listener only now, a failure notification when + * parent couldn't spawn (or lost) a session's store child. No longer + * carries uid/gid/maildir -- those arrive on IMSG_AUTH_CRED above -- so + * a failure reply is just session_id, always zeroed for the rest. + */ #define STORE_MAILDIR_MAX AUTH_MAILDIR_MAX struct imsg_store_fork { uint32_t session_id; - uid_t uid; - gid_t gid; - char maildir[STORE_MAILDIR_MAX]; }; struct imsg_store_init { @@ -427,9 +442,9 @@ struct imsg_store_init { * listener): the first IMSG_MBOX_* pair to actually get a wire payload * shape -- the rest of the family (FETCH/STORE/APPEND/...) is still exactly * as undesigned as store.c's own header comment says. v1 is single-mailbox - * (INBOX only, per openimap-v1-dispatch.md's SELECT row and the still- - * unresolved hierarchy-separator question flagged in listener.c's - * cmd_namespace()) -- readonly distinguishes EXAMINE (RFC 9051 SS6.3.3) + * (INBOX only, and the still-unresolved hierarchy-separator question is + * flagged in listener.c's cmd_namespace()) -- readonly distinguishes + * EXAMINE (RFC 9051 SS6.3.3) * from SELECT, wired up in listener.c's select_or_examine(). store.c still * does nothing different for readonly than for a normal SELECT: v1's single * mailbox returns the identical EXISTS/UIDVALIDITY/UIDNEXT/highestmodseq @@ -441,6 +456,49 @@ struct imsg_store_init { #define MBOX_NAME_MAX 256 /* + * Shared specific-error type for the store-process operation-result imsg + * structs (imsg_mbox_selected, imsg_mbox_status_result, imsg_mbox_result, + * imsg_mbox_appended) -- replaces the old "int ok" plus a bolted-on "int + * no_such_mailbox" that some of these structs used to maintain + * independently for the exact same concept. MBOX_ERR_UNSET is deliberately + * the zero value, not MBOX_OP_OK: every producer memset()s its result + * struct to 0 before filling it in, so a codepath that forgets to set this + * field ends up reporting failure, not silent success. + * + * MBOX_OP_ERR_NO_SUCH_MAILBOX and MBOX_OP_ERR_ALREADY_EXISTS are + * client-visible via RFC 5530 SS3's NONEXISTENT and ALREADYEXISTS codes + * respectively (confirmed directly against the RFC text, not assumed): + * NONEXISTENT's own worked example is a RENAME failing because the + * source doesn't exist, and ALREADYEXISTS's is a CREATE/RENAME target + * that already exists -- both apply directly to CREATE/DELETE/RENAME's + * not-found and already-exists failure modes (added 2026-08-27, task + * #317), correcting an earlier, unverified claim in this comment that no + * RFC 5530 code fit those cases. NO_SUCH_MAILBOX is also COPY/MOVE/ + * APPEND's TRYCREATE trigger (RFC 9051 SS6.4.7/SS6.4.8/SS6.3.12). Every + * other failure cause (mkdir/stat/flock/index races, truncated internal + * buffers, etc.) still collapses to a plain " failed" NO via + * MBOX_OP_ERR_GENERIC -- those genuinely don't fit any RFC 5530 code. + * + * Defined here, ahead of imsg_mbox_select(ed)/imsg_mbox_status_result + * below, rather than down by imsg_mbox_result where it was first added -- + * a straight compile (caught by the 2026-08-27 -Wcast-qual/-Wcast-align + * addition finally forcing a real, non-sandboxed syntax check of this + * header) showed the enum being referenced by struct fields hundreds of + * lines before its old definition, which every C compiler rejects as an + * incomplete type. This project's enum-error-type pass (task #307-309) + * had never actually been compile-checked end to end before now -- + * task #310 ("compile-check + real-hardware verify") was still open + * when this was found. + */ +enum mbox_op_error { + MBOX_ERR_UNSET = 0, + MBOX_OP_OK, + MBOX_OP_ERR_GENERIC, + MBOX_OP_ERR_NO_SUCH_MAILBOX, + MBOX_OP_ERR_ALREADY_EXISTS, +}; + +/* * QRESYNC select-param additions (RFC 7162 SS3.2.5): `"QRESYNC" SP "(" * uidvalidity SP mod-sequence-value [SP known-uids [SP seq-match-data]] * ")"`. v1 scope, sourced against this project's own established pattern @@ -488,8 +546,14 @@ struct imsg_mbox_select { }; struct imsg_mbox_selected { - int ok; /* 0 -- e.g. mailbox isn't INBOX, v1's only - * mailbox -- see openimap-v1-dispatch.md */ + enum mbox_op_error error; /* MBOX_OP_ERR_GENERIC covers both "no + * such mailbox" and an index I/O failure -- + * session_handle_mbox_selected() replies + * "[NONEXISTENT] no such mailbox" (RFC 5530) + * unconditionally on any failure here, so + * there's no NO_SUCH_MAILBOX/other distinction + * for this struct to carry, unlike COPY/MOVE/ + * APPEND's TRYCREATE case */ uint32_t exists; /* RFC 9051 SS7.4.1 EXISTS */ uint32_t uidvalidity; /* RFC 9051 SS2.3.1.1 */ uint32_t uidnext; /* RFC 9051 SS2.3.1.1 */ @@ -546,6 +610,26 @@ struct imsg_mbox_selected { #define STATUS_ATT_DELETED (1U << 4) #define STATUS_ATT_SIZE (1U << 5) #define STATUS_ATT_HIGHESTMODSEQ (1U << 6) +#define STATUS_ATT_RECENT (1U << 7) /* IMAP4rev2 (RFC 9051 + * SS2.3.2) formally + * dropped \Recent and + * RECENT from STATUS's + * status-att grammar, + * but real clients + * (Canary Mail seen + * requesting it + * directly against + * this server) still + * ask for it out of + * IMAP4rev1 habit -- + * always answered "0" + * (no \Recent tracking + * exists in this + * server) rather than + * failing the whole + * STATUS command with + * BAD over one legacy + * attribute name. */ /* * IMSG_MBOX_STATUS (listener -> store) / IMSG_MBOX_STATUS_RESULT (store -> @@ -556,8 +640,8 @@ struct imsg_mbox_selected { * shape here. * * mailbox field added for RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 (flat multi- - * mailbox support -- see docs/openimap-storage-backend.md item 10): this - * comment previously argued a mailbox-name field was unnecessary, "v1 has + * mailbox support): this comment previously argued a mailbox-name field + * was unnecessary, "v1 has * exactly one mailbox (INBOX) and no CREATE" -- no longer true. SS6.3.11 * itself requires STATUS to target *any* named mailbox independent of * whatever this session currently has selected ("asks the server to @@ -596,12 +680,13 @@ struct imsg_mbox_status { }; struct imsg_mbox_status_result { - int ok; /* 0 if the open/flock/index_load sequence - * itself failed -- can't happen due to a bad - * mailbox name (listener.c's gate above - * already ruled that out), but store.c can - * still fail for the same reasons handle_mbox_ - * select() can */ + enum mbox_op_error error; /* MBOX_OP_ERR_GENERIC -- bad mailbox + * name or the open/flock/index_load sequence + * itself failed, same causes handle_mbox_ + * select() can hit; session_handle_mbox_ + * status_result() replies "[NONEXISTENT] no + * such mailbox" unconditionally on any + * failure, so no further distinction needed */ uint32_t messages; /* STATUS_ATT_MESSAGES */ uint32_t uidnext; /* STATUS_ATT_UIDNEXT */ uint32_t uidvalidity; /* STATUS_ATT_UIDVALIDITY */ @@ -705,8 +790,8 @@ struct imsg_mbox_select_vanished { * addressing into a MULTIPART container or a MESSAGE/RFC822|GLOBAL part's * own nested numbering (matching BODYSTRUCTURE's own established message/ * rfc822 scope cut) -- remains deliberately out of this pass. See - * listener.c's cmd_fetch() comment and README.skeleton's entries for each - * item above for the full scoping reasoning. Also v1-scoped: exactly one + * listener.c's cmd_fetch() comment for the full scoping reasoning. + * Also v1-scoped: exactly one * sequence-set range per request (a single number, "a:b", or "*" at either * end) -- listener.c rejects a comma-separated sequence-set before ever * sending this message, rather than silently fetching only the first @@ -746,8 +831,8 @@ struct imsg_mbox_select_vanished { * the same flag-rename + modseq-bump * machinery STORE already has, plus * reflecting the change back in this - * FETCH's own response -- scoped out, - * see README.skeleton). listener.c's + * FETCH's own response -- scoped + * out). listener.c's * parse_fetch_atts() only recognizes the * exact token "BODY.PEEK[HEADER]"; plain * BODY[HEADER] still degrades like every @@ -1455,12 +1540,18 @@ struct imsg_mbox_fetch_bodystructure { * same imsg, capped at BODYSTRUCTURE_MAX */ }; +/* enum mbox_op_error is defined earlier in this file, above + * imsg_mbox_select -- moved there because every struct using it, + * including this one, needs the complete type in scope, and + * imsg_mbox_selected/imsg_mbox_status_result appear before this point. + * See that definition's own comment for the full rationale. */ + /* Generic per-operation completion signal -- FETCH is the first user, * but the name (and the enum's own placement of IMSG_MBOX_RESULT after * every other IMSG_MBOX_* type) suggests it's meant to close out * STORE/APPEND/etc. too once those exist. */ struct imsg_mbox_result { - int ok; + enum mbox_op_error error; uint32_t count; /* number of IMSG_MBOX_FETCH_META (or * equivalent, for a future op) messages that * preceded this one */ @@ -1498,17 +1589,6 @@ struct imsg_mbox_result { * highestmodseq above. */ uint32_t uidvalidity; - - /* - * COPY/MOVE only (0 for every other operation that shares this - * struct, same "populated for the operations that need it" split as - * highestmodseq/uidvalidity above): 1 distinguishes "destination - * mailbox doesn't exist" from any other failure -- listener.c must - * send the tagged NO with a "[TRYCREATE]" prefix per SS6.4.7/SS6.4.8 - * for this case specifically, same distinction imsg_mbox_appended's - * own no_such_mailbox field already makes for APPEND. - */ - int no_such_mailbox; }; /* @@ -1528,8 +1608,8 @@ struct imsg_mbox_result { * doesn't define, so listener.c rejects both before ever building this * struct). Keywords (arbitrary non-"\" atoms, SS2.3.2's `flag-keyword`) * are carried separately as a comma-separated list -- matching the - * index's own on-disk keyword delimiter (openimap-storage-backend.md) so - * store.c can merge them directly without a format conversion; listener.c + * index's own on-disk keyword delimiter so store.c can merge them + * directly without a format conversion; listener.c * rejects any client-supplied keyword containing ':' or ',' (both valid * in IMAP's `atom` grammar, but the index format has no escaping * mechanism for its own field/record delimiters -- see cmd_store_cmd()'s @@ -1679,8 +1759,8 @@ struct imsg_mbox_expunged { * copy_move_dispatch() exactly the way cmd_rename()'s oldname/newname * already are -- mailbox_name_is_inbox()/mailbox_name_valid() first, with * zero store round trip for a syntactically invalid name. Before flat - * multi-mailbox support (RFC 9051 SS6.3.4-SS6.3.6, docs/openimap-storage- - * backend.md item 10) this struct had no destination field at all: v1 had + * multi-mailbox support (RFC 9051 SS6.3.4-SS6.3.6) this struct had no + * destination field at all: v1 had * no CREATE, so the destination was *always* the same mailbox as the * source (the currently selected mailbox, itself always INBOX) -- * explicitly RFC-sanctioned even then (SS6.4.8: "moving a message to the @@ -1797,12 +1877,13 @@ struct imsg_mbox_append { }; struct imsg_mbox_appended { - int ok; - int no_such_mailbox; /* 1 distinguishes "not INBOX" -- + enum mbox_op_error error; /* MBOX_OP_ERR_NO_SUCH_MAILBOX + * distinguishes "not INBOX" -- * listener.c must send the tagged NO * with a "[TRYCREATE]" prefix per * SS6.3.12 -- from any other failure - * (plain NO, no response code) */ + * (MBOX_OP_ERR_GENERIC: plain NO, no + * response code) */ uint32_t uidvalidity; uint32_t uid; /* the appended message's own UID -- * together with uidvalidity, this is @@ -1848,8 +1929,8 @@ struct imsg_mbox_appended { * and refuse cleanly" choice APPEND makes for an oversized message. * * v1 scope, matching this project's smaller-feature-set design - * philosophy (openimap-privsep-design.md): search keys that need actual - * message content or headers -- BCC/BODY/CC/FROM/HEADER/SENTBEFORE/ + * philosophy: search keys that need actual message content or headers + * -- BCC/BODY/CC/FROM/HEADER/SENTBEFORE/ * SENTON/SENTSINCE/SUBJECT/TEXT/TO -- are rejected by listener.c's * parser with a specific NO before this imsg is ever built, the same * "recognized, can't do it right now" category FETCH's BODY[] rejection @@ -2015,8 +2096,7 @@ struct imsg_mbox_idle_refreshed { /* * RFC 9051 SS6.3.4/SS6.3.5 (CREATE/DELETE) and SS6.3.9 (LIST), this pass -- - * see docs/openimap-storage-backend.md's "Open items" #10 for the full - * design (flat, non-nested mailboxes as sibling subdirectories of the + * flat, non-nested mailboxes as sibling subdirectories of the * session's own per-user maildir root; CREATE/DELETE/RENAME all reply with * the existing struct imsg_mbox_result, only "ok" meaningful). listener.c * has already validated the name syntactically (non-empty, not "INBOX", @@ -2104,14 +2184,13 @@ __dead void store_main(void); */ void imsgev_init(struct imsgev *, int, void (*)(int, short, void *), void *); -void imsgev_init_from_ibuf(struct imsgev *, struct imsgbuf *, +void imsgev_init_from_ibuf(struct imsgev *, const struct imsgbuf *, void (*)(int, short, void *), void *); void imsgev_add(struct imsgev *); /* * Boot-time setup-loop helpers, sourced against smtpd.c's setup_proc() - * shape (see openimap-privsep-design.md's "Peer-wiring handshake" - * section): a freshly exec'd child blocks reading fd 3 for zero or more + * shape: a freshly exec'd child blocks reading fd 3 for zero or more * IMSG_SETUP_PEER messages, then an IMSG_SETUP_DONE, and acks. v1's * boot-time wiring is 1:1 (listener gets exactly one peer, auth gets * exactly one) so setup_recv_one_peer() covers both; store's later, blob - /dev/null blob + 8c03107f9c2ffdf3e003d2b7297d2dbb48146bb3 (mode 644) --- /dev/null +++ src/envelope.c @@ -0,0 +1,685 @@ +/* + * Copyright (c) 2026 David Williams + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +/* envelope.c -- the ENVELOPE and BODYSTRUCTURE FETCH response builders. */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imapd.h" +#include "log.h" +#include "store_internal.h" + +int +envbuf_append(char *buf, size_t bufsize, size_t *outlen, const char *data, + size_t datalen) +{ + if (*outlen + datalen > bufsize) + return (-1); + memcpy(buf + *outlen, data, datalen); + *outlen += datalen; + return (0); +} + + +int +envbuf_append_str(char *buf, size_t bufsize, size_t *outlen, const char *s) +{ + return (envbuf_append(buf, bufsize, outlen, s, strlen(s))); +} + +/* Appends one RFC 9051 nstring: NIL if val is NULL, else quoted+escaped; not RFC 2047 decoded (verbatim). */ +int +envbuf_append_nstring(char *buf, size_t bufsize, size_t *outlen, + const char *val, size_t vallen) +{ + size_t i; + + if (val == NULL) + return (envbuf_append_str(buf, bufsize, outlen, "NIL")); + + if (envbuf_append(buf, bufsize, outlen, "\"", 1) == -1) + return (-1); + for (i = 0; i < vallen; i++) { + if ((val[i] == '"' || val[i] == '\\') && + envbuf_append(buf, bufsize, outlen, "\\", 1) == -1) + return (-1); + if (envbuf_append(buf, bufsize, outlen, &val[i], 1) == -1) + return (-1); + } + return (envbuf_append(buf, bufsize, outlen, "\"", 1)); +} + +/* Formats one RFC 5322 mailbox as an IMAP address tuple (RFC 9051 SS9); no group syntax, addr-adl always NIL. */ +int +envbuf_append_one_address(char *buf, size_t bufsize, size_t *outlen, + const char *tok, size_t toklen) +{ + char addrbuf[1024]; + size_t addrlen = 0; + const char *name = NULL; + size_t namelen = 0; + const char *spec; + size_t speclen; + const char *mailbox, *host; + size_t mailboxlen, hostlen; + size_t at; + int found_at; + size_t lt; + + while (toklen > 0 && (tok[0] == ' ' || tok[0] == '\t')) { + tok++; + toklen--; + } + while (toklen > 0 && (tok[toklen - 1] == ' ' || tok[toklen - 1] == '\t')) + toklen--; + if (toklen == 0) + return (-1); + + /* unquoted '<' splits display-name (before) from addr-spec (up to matching unquoted '>') */ + lt = toklen; + { + size_t i; + int q = 0; + + for (i = 0; i < toklen; i++) { + if (tok[i] == '"') + q = !q; + else if (!q && tok[i] == '<') { + lt = i; + break; + } + } + } + + if (lt < toklen) { + size_t gt = toklen, i; + int q = 0; + + for (i = lt + 1; i < toklen; i++) { + if (tok[i] == '"') + q = !q; + else if (!q && tok[i] == '>') { + gt = i; + break; + } + } + if (gt >= toklen) + return (-1); /* unmatched '<' -- malformed, skip */ + + { + const char *disp = tok; + size_t displen = lt; + + while (displen > 0 && (disp[0] == ' ' || disp[0] == '\t')) { + disp++; + displen--; + } + while (displen > 0 && + (disp[displen - 1] == ' ' || disp[displen - 1] == '\t')) + displen--; + + if (displen >= 2 && disp[0] == '"' && + disp[displen - 1] == '"') { + /* emission loop below re-escapes for the wire; no separate unescape pass needed */ + disp++; + displen -= 2; + } + if (displen > 0) { + name = disp; + namelen = displen; + } + } + + spec = tok + lt + 1; + speclen = gt - (lt + 1); + } else { + spec = tok; + speclen = toklen; + } + + while (speclen > 0 && (spec[0] == ' ' || spec[0] == '\t')) { + spec++; + speclen--; + } + while (speclen > 0 && (spec[speclen - 1] == ' ' || spec[speclen - 1] == '\t')) + speclen--; + + found_at = 0; + at = 0; + { + size_t i; + int q = 0; + + for (i = 0; i < speclen; i++) { + if (spec[i] == '"') + q = !q; + else if (!q && spec[i] == '@') { + at = i; + found_at = 1; + } + } + } + if (!found_at || at == 0 || at + 1 >= speclen) + return (-1); /* no usable local-part@domain split */ + + mailbox = spec; + mailboxlen = at; + host = spec + at + 1; + hostlen = speclen - at - 1; + + /* strip surrounding quotes from a quoted local-part (unescaped "@" inside not handled) */ + if (mailboxlen >= 2 && mailbox[0] == '"' && mailbox[mailboxlen - 1] == '"') { + mailbox++; + mailboxlen -= 2; + } + + if (name != NULL) { + size_t j; + + if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "\"", 1) == -1) + return (-1); + for (j = 0; j < namelen; j++) { + char c = name[j]; + + if (c == '\\' && j + 1 < namelen) { + j++; + c = name[j]; + } + if ((c == '"' || c == '\\') && + envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, + "\\", 1) == -1) + return (-1); + if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, + &c, 1) == -1) + return (-1); + } + if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "\"", 1) == -1) + return (-1); + } else { + if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen, "NIL") == -1) + return (-1); + } + + if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen, " NIL ") == -1) + return (-1); + if (envbuf_append_nstring(addrbuf, sizeof(addrbuf), &addrlen, mailbox, + mailboxlen) == -1) + return (-1); + if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, " ", 1) == -1) + return (-1); + if (envbuf_append_nstring(addrbuf, sizeof(addrbuf), &addrlen, host, + hostlen) == -1) + return (-1); + + if (envbuf_append(buf, bufsize, outlen, "(", 1) == -1) + return (-1); + if (envbuf_append(buf, bufsize, outlen, addrbuf, addrlen) == -1) + return (-1); + return (envbuf_append(buf, bufsize, outlen, ")", 1)); +} + +/* Formats an RFC 5322 address-list as "(" 1*address ")", or NIL if none parse (RFC 9051 SS7.5.2); splits on top-level commas only. */ +int +envbuf_append_address_list(char *buf, size_t bufsize, size_t *outlen, + const char *val, size_t vallen) +{ + size_t save = *outlen; + size_t i = 0; + int any = 0; + + while (vallen > 0 && (val[0] == ' ' || val[0] == '\t')) { + val++; + vallen--; + } + while (vallen > 0 && (val[vallen - 1] == ' ' || val[vallen - 1] == '\t')) + vallen--; + + if (vallen == 0) + return (envbuf_append_str(buf, bufsize, outlen, "NIL")); + + if (envbuf_append(buf, bufsize, outlen, "(", 1) == -1) + return (-1); + + while (i < vallen) { + size_t tok_start; + size_t tok_len; + int in_quotes = 0, in_angle = 0; + + while (i < vallen && (val[i] == ' ' || val[i] == '\t' || + val[i] == ',')) + i++; + tok_start = i; + while (i < vallen) { + char c = val[i]; + + if (c == '"') + in_quotes = !in_quotes; + else if (!in_quotes && c == '<') + in_angle = 1; + else if (!in_quotes && c == '>') + in_angle = 0; + else if (!in_quotes && !in_angle && c == ',') + break; + i++; + } + tok_len = i - tok_start; + while (tok_len > 0 && (val[tok_start + tok_len - 1] == ' ' || + val[tok_start + tok_len - 1] == '\t')) + tok_len--; + + if (tok_len > 0) { + if (envbuf_append_one_address(buf, bufsize, outlen, + val + tok_start, tok_len) == 0) + any = 1; + else if (*outlen > bufsize) { + return (-1); /* can't happen; envbuf_append() never overruns bufsize */ + } + /* malformed address: buf/outlen untouched on failure (addrbuf only flushed atomically) */ + } + } + + if (!any) { + *outlen = save; + return (envbuf_append_str(buf, bufsize, outlen, "NIL")); + } + return (envbuf_append(buf, bufsize, outlen, ")", 1)); +} + +/* Looks up header field `name`, appends its nstring form (NIL if absent); shared by ENVELOPE's plain-string members. */ +int +append_field_nstring(char *out, size_t outsize, size_t *outlen, + const char *hdrbuf, uint32_t hdrlen, const char *name) +{ + char *val; + size_t vallen; + int rc; + + if (extract_header_field(hdrbuf, hdrlen, name, &val, &vallen) == 0) { + rc = envbuf_append_nstring(out, outsize, outlen, val, vallen); + free(val); + } else { + rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0); + } + return (rc); +} + +/* Builds RFC 9051 SS7.5.2 ENVELOPE list; Sender/Reply-To default to From if absent/empty; -1 if unreadable or over ENVELOPE_MAX. */ +int +build_envelope(const char *basename, char **buf_out, uint32_t *len_out) +{ + char *hdrbuf = NULL; + uint32_t hdrlen = 0; + char out[ENVELOPE_MAX]; + size_t outlen = 0; + char from_formatted[ENVELOPE_MAX]; + size_t from_len = 0; + + *buf_out = NULL; + *len_out = 0; + + if (read_message_header(basename, &hdrbuf, &hdrlen) == -1) + return (-1); + + if (envbuf_append(out, sizeof(out), &outlen, "(", 1) == -1) + goto fail; + + if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen, + "Date") == -1) + goto fail; + if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1) + goto fail; + if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen, + "Subject") == -1) + goto fail; + if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1) + goto fail; + + /* from */ + { + char *val; + size_t vallen; + + if (extract_header_field(hdrbuf, hdrlen, "From", &val, + &vallen) == 0) { + int rc = envbuf_append_address_list(from_formatted, + sizeof(from_formatted), &from_len, val, vallen); + free(val); + if (rc == -1) + goto fail; + } else { + if (envbuf_append_str(from_formatted, + sizeof(from_formatted), &from_len, "NIL") == -1) + goto fail; + } + } + if (envbuf_append(out, sizeof(out), &outlen, from_formatted, + from_len) == -1) + goto fail; + if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1) + goto fail; + + /* sender, reply-to: default to from_formatted per SS7.5.2 */ + { + static const char *const fallback_fields[] = + { "Sender", "Reply-To" }; + size_t fi; + + for (fi = 0; fi < 2; fi++) { + char *val; + size_t vallen; + int used_value = 0; + + if (extract_header_field(hdrbuf, hdrlen, + fallback_fields[fi], &val, &vallen) == 0) { + if (vallen > 0) { + int rc = envbuf_append_address_list( + out, sizeof(out), &outlen, val, + vallen); + used_value = 1; + free(val); + if (rc == -1) + goto fail; + } else + free(val); + } + if (!used_value && + envbuf_append(out, sizeof(out), &outlen, + from_formatted, from_len) == -1) + goto fail; + if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1) + goto fail; + } + } + + /* to, cc, bcc */ + { + static const char *const addr_fields[] = { "To", "Cc", "Bcc" }; + size_t fi; + + for (fi = 0; fi < 3; fi++) { + char *val; + size_t vallen; + + if (extract_header_field(hdrbuf, hdrlen, + addr_fields[fi], &val, &vallen) == 0) { + int rc = envbuf_append_address_list(out, + sizeof(out), &outlen, val, vallen); + free(val); + if (rc == -1) + goto fail; + } else { + if (envbuf_append_str(out, sizeof(out), + &outlen, "NIL") == -1) + goto fail; + } + if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1) + goto fail; + } + } + + if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen, + "In-Reply-To") == -1) + goto fail; + if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1) + goto fail; + if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen, + "Message-Id") == -1) + goto fail; + + if (envbuf_append(out, sizeof(out), &outlen, ")", 1) == -1) + goto fail; + + free(hdrbuf); + + if ((*buf_out = malloc(outlen)) == NULL) { + log_warn("session %u: malloc ENVELOPE buffer (%s)", + session_id, basename); + return (-1); + } + memcpy(*buf_out, out, outlen); + *len_out = (uint32_t)outlen; + return (0); + +fail: + log_warnx("session %u: message %s: formatted ENVELOPE exceeds " + "ENVELOPE_MAX -- ENVELOPE skipped", session_id, basename); + free(hdrbuf); + return (-1); +} + +/* BODYSTRUCTURE (RFC 9051 SS7.5.2): recursive RFC 2045/2046 MIME parse, bounded by MIME_MAX_DEPTH/MIME_MAX_PARTS; no extension data, message/rfc822, or RFC 2231 continuations. */ +int +build_body_structure(int depth, int *nparts_used, const char *hdr, + size_t hdrlen, const char *body, size_t bodylen, char *out, + size_t outsize, size_t *outlen) +{ + char type[64], subtype[64]; + char params_fmt[600]; + char boundary[70 + 1]; /* RFC 2046 SS5.1.1 caps boundary at 70 chars, +1 NUL */ + int has_boundary; + + if (depth > MIME_MAX_DEPTH) + return (-1); + if (++*nparts_used > MIME_MAX_PARTS) + return (-1); + + params_fmt[0] = '\0'; + if (parse_content_type(hdr, hdrlen, type, sizeof(type), subtype, + sizeof(subtype), params_fmt, sizeof(params_fmt), boundary, + sizeof(boundary), &has_boundary) == -1) + return (-1); + + if (strcasecmp(type, "MULTIPART") == 0) { + size_t part_starts[MIME_MAX_PARTS], part_ends[MIME_MAX_PARTS]; + int n, i; + + if (!has_boundary) + return (-1); + if (split_multipart(body, bodylen, boundary, part_starts, + part_ends, &n, MIME_MAX_PARTS) == -1) + return (-1); + + if (envbuf_append(out, outsize, outlen, "(", 1) == -1) + return (-1); + for (i = 0; i < n; i++) { + const char *pbuf = body + part_starts[i]; + size_t plen = part_ends[i] - part_starts[i]; + size_t phdrend; + + /* zero-length body-part is spec-legal (RFC 2046 SS5.1.1); treat as hdrlen==0/bodylen==0 directly */ + if (plen == 0) + phdrend = 0; + else if (find_header_body_split(pbuf, plen, + &phdrend) == -1) + return (-1); + if (build_body_structure(depth + 1, nparts_used, pbuf, + phdrend, pbuf + phdrend, plen - phdrend, out, + outsize, outlen) == -1) + return (-1); + } + if (envbuf_append(out, outsize, outlen, " ", 1) == -1) + return (-1); + if (envbuf_append_nstring(out, outsize, outlen, subtype, + strlen(subtype)) == -1) + return (-1); + return (envbuf_append(out, outsize, outlen, ")", 1)); + } + + if (strcasecmp(type, "MESSAGE") == 0 && + (strcasecmp(subtype, "RFC822") == 0 || + strcasecmp(subtype, "GLOBAL") == 0)) + return (-1); /* scoped out, see BODYSTRUCTURE comment above */ + + { + char *idval = NULL, *descval = NULL, *encval = NULL; + size_t idlen = 0, desclen = 0, enclen = 0; + char encstr[40]; + int is_text = (strcasecmp(type, "TEXT") == 0); + int rc = 0; + + if (envbuf_append(out, outsize, outlen, "(", 1) == -1) + return (-1); + if (envbuf_append_nstring(out, outsize, outlen, type, + strlen(type)) == -1) + return (-1); + if (envbuf_append(out, outsize, outlen, " ", 1) == -1) + return (-1); + if (envbuf_append_nstring(out, outsize, outlen, subtype, + strlen(subtype)) == -1) + return (-1); + if (envbuf_append(out, outsize, outlen, " ", 1) == -1) + return (-1); + if (envbuf_append(out, outsize, outlen, params_fmt, + strlen(params_fmt)) == -1) + return (-1); + if (envbuf_append(out, outsize, outlen, " ", 1) == -1) + return (-1); + + if (extract_header_field(hdr, hdrlen, "Content-Id", &idval, + &idlen) == 0) + rc = envbuf_append_nstring(out, outsize, outlen, idval, + idlen); + else + rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0); + free(idval); + if (rc == -1) + return (-1); + if (envbuf_append(out, outsize, outlen, " ", 1) == -1) + return (-1); + + if (extract_header_field(hdr, hdrlen, "Content-Description", + &descval, &desclen) == 0) + rc = envbuf_append_nstring(out, outsize, outlen, + descval, desclen); + else + rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0); + free(descval); + if (rc == -1) + return (-1); + if (envbuf_append(out, outsize, outlen, " ", 1) == -1) + return (-1); + + if (extract_header_field(hdr, hdrlen, + "Content-Transfer-Encoding", &encval, &enclen) == 0 && + enclen > 0 && enclen < sizeof(encstr)) { + memcpy(encstr, encval, enclen); + encstr[enclen] = '\0'; + } else if (strlcpy(encstr, "7BIT", sizeof(encstr)) >= + sizeof(encstr)) { + return (-1); + } + free(encval); + { + static const char *const known[] = { "7BIT", "8BIT", + "BINARY", "BASE64", "QUOTED-PRINTABLE" }; + size_t ki; + + for (ki = 0; ki < 5; ki++) { + if (strcasecmp(encstr, known[ki]) == 0) { + if (strlcpy(encstr, known[ki], + sizeof(encstr)) >= sizeof(encstr)) + return (-1); + break; + } + } + } + if (envbuf_append_nstring(out, outsize, outlen, encstr, + strlen(encstr)) == -1) + return (-1); + if (envbuf_append(out, outsize, outlen, " ", 1) == -1) + return (-1); + + { + char numbuf[32]; + + snprintf(numbuf, sizeof(numbuf), "%zu", bodylen); + if (envbuf_append_str(out, outsize, outlen, numbuf) == -1) + return (-1); + } + + if (is_text) { + size_t lines = 0, li; + char numbuf2[32]; + + for (li = 0; li < bodylen; li++) { + if (body[li] == '\n') + lines++; + } + snprintf(numbuf2, sizeof(numbuf2), " %zu", lines); + if (envbuf_append_str(out, outsize, outlen, numbuf2) == -1) + return (-1); + } + + return (envbuf_append(out, outsize, outlen, ")", 1)); + } +} + +/* Top-level entry: reads message once (capped at bodystructure_read_max), finds header/body split, walks from depth 0; -1 on any failure. */ +int +build_bodystructure(const char *basename, char **buf_out, uint32_t *len_out) +{ + char *wholebuf = NULL; + uint32_t wholelen = 0; + size_t hdrend; + char out[BODYSTRUCTURE_MAX]; + size_t outlen = 0; + int nparts_used = 0; + + *buf_out = NULL; + *len_out = 0; + + if (read_message_body(basename, 0, bodystructure_read_max, + "BODYSTRUCTURE", &wholebuf, &wholelen) == -1) + return (-1); + if (wholelen == 0 || + find_header_body_split(wholebuf, wholelen, &hdrend) == -1) { + free(wholebuf); + return (-1); + } + + if (build_body_structure(0, &nparts_used, wholebuf, hdrend, + wholebuf + hdrend, wholelen - hdrend, out, sizeof(out), + &outlen) == -1) { + free(wholebuf); + return (-1); + } + free(wholebuf); + + if ((*buf_out = malloc(outlen)) == NULL) { + log_warn("session %u: malloc BODYSTRUCTURE buffer (%s)", + session_id, basename); + return (-1); + } + memcpy(*buf_out, out, outlen); + *len_out = (uint32_t)outlen; + return (0); +} + +/* Parses RFC 9051 SS6.4.5.1 section-part (e.g. "3.1") into 1-based part numbers; re-validates untrusted input. -1 on bad syntax or > maxpath. */ blob - 2afc427465067cbf551fb84c000c437ffdacda0a blob + 55cbf3e19a7d339b4fb4f479c379a596688464fb --- src/imsgev.c +++ src/imsgev.c @@ -1,6 +1,15 @@ /* * Copyright (c) 2026 David Williams + * Copyright (c) 2009 Eric Faurot * + * This file's name and its "struct imsgev" wrapper-around-imsgbuf+ + * event(3) concept match Eric Faurot's imsgev.c in OpenBSD's ldapd + * (usr.sbin/ldapd/imsgev.c) -- not a generic/obvious name, so his + * copyright is carried forward here even though this file's actual + * function signatures and dispatch design (a caller-supplied raw + * libevent handler, vs. ldapd's callback+needfd model) were written + * independently and differ from his implementation. + * * Permission to use, copy, modify, and distribute this software for any * purpose with or without fee is hereby granted, provided that the above * copyright notice and this permission notice appear in all copies. @@ -15,16 +24,8 @@ */ /* - * Small shared wrapper around imsgbuf + event(3), used identically by - * parent.c, listener.c, auth.c, and store.c. Not itself sourced from any - * uploaded file this session -- see the comment on struct imsgev in - * openimap.h. - * - * API NAMES: checked against the real src/imsg.h this session (see - * parent.c's header comment) -- imsgbuf_set_maxsize() and - * imsgbuf_queuelen() both match the real header exactly. - * MAX_IMSGSIZE itself *is* sourced (imsg_init(3), cited throughout - * openimap-privsep-design.md). + * imsgev.c -- shared wrapper around imsgbuf + event(3), used by + * parent.c, listener.c, auth.c, and store.c. */ #include @@ -44,15 +45,7 @@ imsgev_init(struct imsgev *iev, int fd, void (*handler fatal("imsgbuf_init"); imsgbuf_set_maxsize(&iev->ibuf, MAX_IMSGSIZE); - /* - * Every channel wrapped by struct imsgev either sends or receives - * fd-passed messages somewhere in its lifetime (listening sockets, - * SETUP_PEER peer fds, later store-peer fds) -- imsgbuf_allow_fdpass() - * is real (confirmed against src/imsg.h) and was missing from this - * skeleton entirely until this pass. Unconditional here rather than - * per-caller since there's no channel in this design that *never* - * passes an fd. - */ + /* every channel fd-passes something at some point; allow it always */ imsgbuf_allow_fdpass(&iev->ibuf); iev->handler = handler; @@ -63,31 +56,9 @@ imsgev_init(struct imsgev *iev, int fd, void (*handler event_add(&iev->ev, NULL); } -/* - * Like imsgev_init(), but for a channel whose struct imsgbuf has already - * been imsgbuf_init()'d and read from -- e.g. a fd-3 boot channel that a - * child process drained synchronously (before event_init() was even - * callable) and now wants to hand off to the event loop for the rest of - * its life. Takes ownership of *ibuf by copying it into iev->ibuf, NOT by - * calling imsgbuf_init() again. - * - * This distinction matters: unlike struct imsgev (which embeds a struct - * event -- see the struct store_child comment in parent.c for why THAT - * must never be copied once registered with libevent), struct imsgbuf - * itself holds no libevent registration and its only heap state (`w`) is - * a pointer, so copying it by value is safe and standard -- but calling - * imsgbuf_init() a *second* time on the same fd would silently discard - * any bytes imsgbuf_read() had already buffered beyond the messages the - * caller happened to have consumed so far (parent doesn't wait for acks - * between sends, so more than one message can already be sitting in the - * kernel socket buffer -- and possibly already pulled into ibuf's - * internal state -- by the time a child gets around to reading it). - * imsgev_init() alone would have that bug; this function exists so - * listener.c's fd-3 channel (parent) doesn't hit it. See listener.c's - * listener_main() for the caller. - */ +/* like imsgev_init(), but copies an already-init'd *ibuf instead of re-init'ing (would discard buffered bytes) */ void -imsgev_init_from_ibuf(struct imsgev *iev, struct imsgbuf *ibuf, +imsgev_init_from_ibuf(struct imsgev *iev, const struct imsgbuf *ibuf, void (*handler)(int, short, void *), void *data) { iev->ibuf = *ibuf; @@ -101,11 +72,7 @@ imsgev_init_from_ibuf(struct imsgev *iev, struct imsgb event_add(&iev->ev, NULL); } -/* - * Re-arm after composing an outgoing message: watch EV_WRITE too if the - * imsgbuf has queued, unflushed output. Callers' dispatch handlers should - * call this at the end of any code path that calls imsg_compose(). - */ +/* re-arm after imsg_compose(); adds EV_WRITE if output is queued. Call at the end of any compose path. */ void imsgev_add(struct imsgev *iev) { @@ -119,31 +86,7 @@ imsgev_add(struct imsgev *iev) event_add(&iev->ev, NULL); } -/* - * Blocks (no event loop running yet) for exactly one IMSG_SETUP_PEER on - * ibuf3 (already imsgbuf_init()'d on fd 3 by the caller) and returns the - * fd-passed peer fd. fatalx()s on anything else -- matches smtpd's - * setup_proc() treating an unexpected message during setup as fatal - * ("bad imsg %d"). - * - * Real deadlock caught on first real-hardware run (OpenBSD, not this - * sandbox): parent.c sends IMSG_SETUP_PEER and IMSG_SETUP_DONE back to - * back on the same socketpair fd (setup_peer_send() then - * setup_done_send(), both flushed immediately, no wait in between). On a - * SOCK_STREAM socketpair the kernel is free to coalesce both sends into - * one readable chunk -- confirmed via ktrace(1) on the live hang: a - * single recvmsg() here returned 32 bytes (both 16-byte imsg headers) - * instead of 16. imsg_get() correctly peels off just the first message - * and leaves the second one fully buffered in ibuf3's own userspace - * state -- but the *caller* (setup_recv_done_and_ack(), immediately - * next) used to call imsgbuf_read() unconditionally before ever checking - * whether a complete message was already sitting there, so it issued a - * second blocking recvmsg() for bytes that were never coming, while - * parent sat blocked reading this process's now-overdue SETUP_DONE ack. - * Fixed by checking imsg_get() FIRST in both this loop and - * setup_recv_done_and_ack()'s below -- imsgbuf_read() (an actual - * blocking syscall) only happens when nothing is already buffered. - */ +/* blocks for one IMSG_SETUP_PEER, returns its fd-passed fd; imsg_get() checked before imsgbuf_read() to avoid coalesced-message stalls */ int setup_recv_one_peer(struct imsgbuf *ibuf3) { @@ -173,18 +116,7 @@ setup_recv_one_peer(struct imsgbuf *ibuf3) return (fd); } -/* - * Blocks for IMSG_SETUP_DONE on ibuf3, then sends one back as an ack. - * Sourced against smtpd.c's setup_proc() loop (IMSG_SETUP_DONE case sets - * a "done" flag and exits the loop; the ack-back send is the same - * imsg_compose(ibuf, IMSG_SETUP_DONE, 0, 0, -1, NULL, 0) shape quoted in - * the design doc from setup_proc()'s tail). - * - * imsg_get()-before-imsgbuf_read() ordering: see setup_recv_one_peer()'s - * header comment above -- this is the specific call site where the real - * deadlock happened (IMSG_SETUP_DONE arrives already-buffered, coalesced - * with the preceding IMSG_SETUP_PEER read by the caller). - */ +/* blocks for IMSG_SETUP_DONE, then sends one back as an ack (see setup_recv_one_peer() re: imsg_get() ordering) */ void setup_recv_done_and_ack(struct imsgbuf *ibuf3) { blob - /dev/null blob + 075fcd37dae4bde752279995e76c977001f0e82a (mode 644) --- /dev/null +++ src/fetch_cmd.c @@ -0,0 +1,977 @@ +/* + * Copyright (c) 2026 David Williams + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +/* + * fetch_cmd.c -- FETCH: attribute/section-spec parsing and + * response building. + */ + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imapd.h" +#include "log.h" +#include "listener.h" + +int +parse_nz_number(const char *str, uint32_t *out) +{ + unsigned long v; + char *end; + + if (str == NULL || *str == '\0' || *str == '0') + return (-1); + + errno = 0; + v = strtoul(str, &end, 10); + if (*end != '\0' || errno == ERANGE || v == 0 || v > UINT32_MAX) + return (-1); + + *out = (uint32_t)v; + return (0); +} + +/* RFC 9051 SS9 seq-range; "*" unresolved here, carried via lo_star/hi_star to store.c; backwards literal range swapped */ +int +parse_seq_range(const char *tok, uint32_t *lo, uint32_t *hi, int *lo_star, + int *hi_star) +{ + char buf[32]; + char *colon; + const char *loside, *hiside; + + if (tok == NULL || *tok == '\0' || strlen(tok) >= sizeof(buf)) + return (-1); + if (strlcpy(buf, tok, sizeof(buf)) >= sizeof(buf)) + return (-1); + + *lo_star = *hi_star = 0; + *lo = *hi = 0; + + if ((colon = strchr(buf, ':')) != NULL) { + *colon = '\0'; + loside = buf; + hiside = colon + 1; + } else { + loside = buf; + hiside = buf; + } + + if (strcmp(loside, "*") == 0) + *lo_star = 1; + else if (parse_nz_number(loside, lo) == -1) + return (-1); + + if (strcmp(hiside, "*") == 0) + *hi_star = 1; + else if (parse_nz_number(hiside, hi) == -1) + return (-1); + + if (!*lo_star && !*hi_star && *lo > *hi) { + uint32_t tmp = *lo; + + *lo = *hi; + *hi = tmp; + } + + return (0); +} + +/* like strtok_r(str, " ", &savep), but space isn't a delimiter inside an unclosed '[' or '(' (RFC 9051 SS9 header-list) */ +static char * +fetch_att_tok(char *str, char **savep) +{ + char *p, *start; + int depth = 0; + + p = (str != NULL) ? str : *savep; + + while (*p == ' ') + p++; + if (*p == '\0') { + *savep = p; + return (NULL); + } + + for (start = p; *p != '\0'; p++) { + if (*p == '[' || *p == '(') + depth++; + else if (*p == ']' || *p == ')') { + if (depth > 0) + depth--; + } else if (*p == ' ' && depth == 0) + break; + } + + if (*p != '\0') { + *p = '\0'; + p++; + } + *savep = p; + return (start); +} + +/* parses "HEADER.FIELDS[.NOT] (name ...)" bracket body (RFC 9051 SS9); -1 on syntax error is a real client BAD, not a silent drop */ +int +parse_header_fields_att(const char *inner, int *not_out, char *fields_out, + size_t fields_outsize) +{ + char listbuf[HEADER_FIELDS_LABEL_MAX]; + char *p = listbuf, *listp, *end; + char *name, *save; + int first = 1; + + *not_out = 0; + fields_out[0] = '\0'; + + if (strlcpy(listbuf, inner, sizeof(listbuf)) >= sizeof(listbuf)) + return (-1); + + if (strncasecmp(p, "HEADER.FIELDS", 13) != 0) + return (-1); + p += 13; + + if (strncasecmp(p, ".NOT", 4) == 0) { + *not_out = 1; + p += 4; + } + + if (*p != ' ') + return (-1); + p++; + + listp = p; + if (*listp != '(') + return (-1); + listp++; + + end = strchr(listp, ')'); + if (end == NULL || end[1] != '\0') + return (-1); + *end = '\0'; + + if (*listp == '\0') + return (-1); /* header-list requires at least one name */ + + for (name = strtok_r(listp, " ", &save); name != NULL; + name = strtok_r(NULL, " ", &save)) { + if (strchr(name, '"') != NULL) + return (-1); + if (!first && + strlcat(fields_out, " ", fields_outsize) >= fields_outsize) + return (-1); + if (strlcat(fields_out, name, fields_outsize) >= fields_outsize) + return (-1); + first = 0; + } + + return (0); +} + +/* RFC 9051 SS6.4.5.1 section-part grammar check; verbatim string still crosses to store.c's parse_section_part() */ +int +section_part_valid(const char *s) +{ + int n = 0; + + if (s == NULL || *s == '\0') + return (0); + + while (*s != '\0') { + if (*s < '1' || *s > '9') /* nz-number: digit-nz first */ + return (0); + if (n >= MIME_MAX_DEPTH) + return (0); + n++; + while (*s >= '0' && *s <= '9') + s++; + if (*s == '\0') + return (1); + if (*s != '.') + return (0); + s++; + if (*s == '\0') + return (0); /* trailing dot */ + } + return (1); +} + +/* RFC 9051 SS6.4.5 partial-range suffix ""; count may be 0 (apply_partial_range() in store.c handles that) */ +int +parse_partial_suffix(const char *s, int *has_partial_out, + uint32_t *start_out, uint32_t *count_out) +{ + const char *p; + char *end; + unsigned long start, count; + + *has_partial_out = 0; + *start_out = 0; + *count_out = 0; + + if (s == NULL || *s == '\0') + return (0); + + if (s[0] != '<') + return (-1); + p = s + 1; + + if (*p < '0' || *p > '9') + return (-1); + errno = 0; + start = strtoul(p, &end, 10); + if (errno != 0 || start > UINT32_MAX || *end != '.') + return (-1); + p = end + 1; + + if (*p < '0' || *p > '9') + return (-1); + errno = 0; + count = strtoul(p, &end, 10); + if (errno != 0 || count > UINT32_MAX || *end != '>' || end[1] != '\0') + return (-1); + + *has_partial_out = 1; + *start_out = (uint32_t)start; + *count_out = (uint32_t)count; + return (0); +} + +/* RFC 9051 SS6.4.5 fetch-att + ALL/FULL/FAST macros; unsupported items silently skipped (*degraded_out=1) unless all are, then -2/NO */ +int +parse_fetch_atts(char *spec, uint32_t *attrs_out, int *degraded_out, + int *header_fields_not_out, char *header_fields_out, + size_t header_fields_outsize, char *header_fields_label_out, + size_t header_fields_label_outsize, int *bodystructure_full_out, + char *section_part_out, size_t section_part_outsize, + int *has_partial_out, uint32_t *partial_start_out, + uint32_t *partial_count_out, const char **errmsg) +{ + char *p, *tok, *save; + size_t len; + uint32_t attrs = 0; + int degraded = 0; + int has_partial = 0; + uint32_t partial_start = 0, partial_count = 0; + + *errmsg = NULL; + *attrs_out = 0; + *degraded_out = 0; + *header_fields_not_out = 0; + header_fields_out[0] = '\0'; + header_fields_label_out[0] = '\0'; + *bodystructure_full_out = 0; + section_part_out[0] = '\0'; + *has_partial_out = 0; + *partial_start_out = 0; + *partial_count_out = 0; + + if (spec == NULL || *spec == '\0') { + *errmsg = "missing message data item(s)"; + return (-1); + } + + p = spec; + len = strlen(p); + if (len >= 2 && p[0] == '(' && p[len - 1] == ')') { + p[len - 1] = '\0'; + p++; + } + if (*p == '\0') { + *errmsg = "empty message data item list"; + return (-1); + } + + for (tok = fetch_att_tok(p, &save); tok != NULL; + tok = fetch_att_tok(NULL, &save)) { + if (strcasecmp(tok, "FAST") == 0) { + /* SS6.4.5 macro: FLAGS INTERNALDATE RFC822.SIZE. */ + attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE | + MBOX_FETCH_RFC822_SIZE; + } else if (strcasecmp(tok, "ALL") == 0) { + /* SS6.4.5 macro: FAST + ENVELOPE. */ + attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE | + MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE; + } else if (strcasecmp(tok, "FULL") == 0) { + /* SS6.4.5 macro: ALL + bare BODY (bodystructure_full_out stays 0) */ + attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE | + MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE | + MBOX_FETCH_BODYSTRUCTURE; + } else if (strcasecmp(tok, "FLAGS") == 0) { + attrs |= MBOX_FETCH_FLAGS; + } else if (strcasecmp(tok, "UID") == 0) { + attrs |= MBOX_FETCH_UID; + } else if (strcasecmp(tok, "INTERNALDATE") == 0) { + attrs |= MBOX_FETCH_INTERNALDATE; + } else if (strcasecmp(tok, "RFC822.SIZE") == 0) { + attrs |= MBOX_FETCH_RFC822_SIZE; + } else if (strcasecmp(tok, "MODSEQ") == 0) { + attrs |= MBOX_FETCH_MODSEQ; /* RFC 7162 SS3.1.4.2 -- also CONDSTORE-enabling, cmd_fetch() checks this bit */ + } else if (strcasecmp(tok, "BODY.PEEK[HEADER]") == 0) { + attrs |= MBOX_FETCH_BODY_HEADER; /* the one exact-match BODY[...]; plain BODY[HEADER] would need \Seen, unimplemented */ + } else if (strncasecmp(tok, "BODY.PEEK[", strlen("BODY.PEEK[")) == + 0 && strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS", + strlen("BODY.PEEK[HEADER.FIELDS")) != 0) { + /* every other BODY.PEEK[...] shape: [], [TEXT], or [], optional <> (SS6.4.5) */ + const char *bracket_start = tok + + strlen("BODY.PEEK["); + char *close; + char inner[SECTION_PART_MAX]; + const char *suffix; + + close = strchr(bracket_start, ']'); + if (close == NULL) { + degraded = 1; /* not well-bracketed -- same lenient skip as other unsupported forms */ + continue; + } + if ((size_t)(close - bracket_start) >= sizeof(inner)) { + degraded = 1; + continue; + } + memcpy(inner, bracket_start, close - bracket_start); + inner[close - bracket_start] = '\0'; + suffix = close + 1; + + if (suffix[0] != '\0' && + parse_partial_suffix(suffix, &has_partial, + &partial_start, &partial_count) == -1) { + *errmsg = "malformed range"; + return (-1); + } + + if (inner[0] == '\0') { + attrs |= MBOX_FETCH_BODY_WHOLE; + } else if (strcasecmp(inner, "TEXT") == 0) { + attrs |= MBOX_FETCH_BODY_TEXT; + } else if (section_part_valid(inner)) { + attrs |= MBOX_FETCH_BODY_PART; + if (strlcpy(section_part_out, inner, + section_part_outsize) >= + section_part_outsize) { + attrs &= ~MBOX_FETCH_BODY_PART; + degraded = 1; + continue; + } + } else { + degraded = 1; /* recognized shape, unsupported section (e.g. "2.1.TEXT") */ + continue; + } + } else if (strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS", + strlen("BODY.PEEK[HEADER.FIELDS")) == 0) { + /* prefix-matched (field-name list varies); a second HEADER.FIELDS item is silently ignored */ + size_t toklen = strlen(tok); + char inner[HEADER_FIELDS_LABEL_MAX]; + + if (toklen < strlen("BODY.PEEK[") + 1 || + tok[toklen - 1] != ']') { + *errmsg = "malformed HEADER.FIELDS section"; + return (-1); + } + if (attrs & MBOX_FETCH_HEADER_FIELDS) + continue; /* already captured one -- ignore any further duplicates */ + + if (toklen - strlen("BODY.PEEK[") - 1 >= + sizeof(inner)) { + *errmsg = "HEADER.FIELDS section too long"; + return (-1); + } + memcpy(inner, tok + strlen("BODY.PEEK["), + toklen - strlen("BODY.PEEK[") - 1); + inner[toklen - strlen("BODY.PEEK[") - 1] = '\0'; + + if (parse_header_fields_att(inner, + header_fields_not_out, header_fields_out, + header_fields_outsize) == -1) { + *errmsg = "malformed HEADER.FIELDS section"; + return (-1); + } + if (strlcpy(header_fields_label_out, inner, + header_fields_label_outsize) >= + header_fields_label_outsize) { + *errmsg = "HEADER.FIELDS section too long"; + return (-1); + } + attrs |= MBOX_FETCH_HEADER_FIELDS; + } else if (strcasecmp(tok, "ENVELOPE") == 0) { + attrs |= MBOX_FETCH_ENVELOPE; /* RFC 9051 SS7.5.2; no .PEEK variant, no \Seen side effect */ + } else if (strcasecmp(tok, "BODY") == 0 || + strcasecmp(tok, "BODYSTRUCTURE") == 0) { + /* both produce identical output; exact-matched ahead of the "BODY" prefix catch-all below */ + attrs |= MBOX_FETCH_BODYSTRUCTURE; + *bodystructure_full_out = + (strcasecmp(tok, "BODYSTRUCTURE") == 0); + } else if (strncasecmp(tok, "BODY", 4) == 0 || + strcasecmp(tok, "RFC822") == 0 || + strcasecmp(tok, "RFC822.HEADER") == 0 || + strcasecmp(tok, "RFC822.TEXT") == 0) { + /* MIME part-addressed BODY[...] and RFC822(.HEADER/.TEXT) shorthands unimplemented; dropped */ + degraded = 1; + } else { + *errmsg = "unknown message data item"; + return (-1); + } + } + + if (attrs == 0) { + /* every requested item was unsupported, e.g. BODY[] or RFC822(.HEADER/.TEXT) alone */ + *errmsg = "cannot fetch that message content yet -- " + "supported: FLAGS/UID/INTERNALDATE/RFC822.SIZE/MODSEQ/" + "ENVELOPE/(BODY|BODYSTRUCTURE)/BODY.PEEK[...]"; + return (-2); + } + + /* both HEADER and HEADER.FIELDS requested (legal, SS6.4.5): HEADER wins, only one pending_header_* slot exists */ + if ((attrs & MBOX_FETCH_BODY_HEADER) && + (attrs & MBOX_FETCH_HEADER_FIELDS)) + attrs &= ~MBOX_FETCH_HEADER_FIELDS; + + *attrs_out = attrs; + *degraded_out = degraded; + /* accumulated in locals like attrs, copied out here so the HEADER-wins resolution above stays the one adjustment point */ + *has_partial_out = has_partial; + *partial_start_out = partial_start; + *partial_count_out = partial_count; + return (0); +} + +const char *fetch_month_names[12] = { + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" +}; + +/* RFC 9051 SS9 date-time; always formats in UTC "+0000" -- ts carries no tz info and a chroot'd store child has no tzdata */ +void +format_internaldate(int64_t ts, char *out, size_t outsize) +{ + struct tm tm; + time_t t = (time_t)ts; + + if (gmtime_r(&t, &tm) == NULL) { + if (strlcpy(out, "01-Jan-1970 00:00:00 +0000", outsize) >= + outsize) + log_warnx("format_internaldate: fallback string " + "truncated -- caller's buffer too small"); + return; + } + + snprintf(out, outsize, "%2d-%s-%04d %02d:%02d:%02d +0000", + tm.tm_mday, fetch_month_names[tm.tm_mon], tm.tm_year + 1900, + tm.tm_hour, tm.tm_min, tm.tm_sec); +} + +/* snprintf-into-growing-buffer helper; clamps *len to bufsize so a prior truncation can't underflow the next call's remaining size */ +static void +fetch_append(char *buf, size_t bufsize, size_t *len, const char *fmt, ...) +{ + va_list ap; + int n; + + if (*len >= bufsize) + return; + + va_start(ap, fmt); + n = vsnprintf(buf + *len, bufsize - *len, fmt, ap); + va_end(ap); + + if (n < 0) + return; + + *len += (size_t)n; + if (*len > bufsize) + *len = bufsize; +} + +/* sends one untagged "* FETCH (...)" (RFC 9051 SS7.5.2); literal-syntax items flush buf then write their payload raw */ +void +session_send_fetch_response(struct session *s, + struct imsg_mbox_fetch_meta *meta) +{ + char buf[768]; + char date[40]; + size_t len = 0; + int need_sp = 0; + int have_header = (s->fetch_attrs & (MBOX_FETCH_BODY_HEADER | + MBOX_FETCH_HEADER_FIELDS)) && s->pending_header_found; + int have_body = (s->fetch_attrs & + (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT | + MBOX_FETCH_BODY_PART)) && s->pending_body_found; + int have_envelope = (s->fetch_attrs & MBOX_FETCH_ENVELOPE) && + s->pending_envelope_found; + int have_bodystructure = (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE) && + s->pending_bodystructure_found; + + fetch_append(buf, sizeof(buf), &len, "%u FETCH (", meta->seqno); + + if (s->fetch_attrs & MBOX_FETCH_FLAGS) { + fetch_append(buf, sizeof(buf), &len, "FLAGS (%s)", meta->flags); + need_sp = 1; + } + if (s->fetch_attrs & MBOX_FETCH_UID) { + fetch_append(buf, sizeof(buf), &len, "%sUID %u", + need_sp ? " " : "", meta->uid); + need_sp = 1; + } + if (s->fetch_attrs & MBOX_FETCH_INTERNALDATE) { + format_internaldate(meta->internaldate, date, sizeof(date)); + fetch_append(buf, sizeof(buf), &len, "%sINTERNALDATE \"%s\"", + need_sp ? " " : "", date); + need_sp = 1; + } + if (s->fetch_attrs & MBOX_FETCH_RFC822_SIZE) { + fetch_append(buf, sizeof(buf), &len, "%sRFC822.SIZE %llu", + need_sp ? " " : "", (unsigned long long)meta->size); + need_sp = 1; + } + if (s->fetch_attrs & MBOX_FETCH_MODSEQ) { + /* RFC 7162 SS3.1.4.2 fetch-mod-resp */ + fetch_append(buf, sizeof(buf), &len, "%sMODSEQ (%llu)", + need_sp ? " " : "", (unsigned long long)meta->modseq); + need_sp = 1; + } + + if (have_header || have_body || have_envelope || have_bodystructure) { + session_write(s, "* ", 2); + session_write(s, buf, len); + + if (have_envelope) { + len = 0; + fetch_append(buf, sizeof(buf), &len, "%sENVELOPE ", + need_sp ? " " : ""); + session_write(s, buf, len); + if (s->pending_envelope_len > 0) + session_write(s, s->pending_envelope_buf, + s->pending_envelope_len); + need_sp = 1; + } + if (have_bodystructure) { + len = 0; + fetch_append(buf, sizeof(buf), &len, "%s%s ", + need_sp ? " " : "", s->pending_bodystructure_label); + session_write(s, buf, len); + if (s->pending_bodystructure_len > 0) + session_write(s, s->pending_bodystructure_buf, + s->pending_bodystructure_len); + need_sp = 1; + } + if (have_header) { + len = 0; + fetch_append(buf, sizeof(buf), &len, + "%sBODY[%s] {%u}\r\n", need_sp ? " " : "", + s->pending_header_label, s->pending_header_len); + session_write(s, buf, len); + if (s->pending_header_len > 0) + session_write(s, s->pending_header_buf, + s->pending_header_len); + need_sp = 1; + } + if (have_body) { + /* RFC 9051 SS6.4.5: echo the origin octet only if the client sent one; never echo store.c's count */ + len = 0; + if (s->pending_body_has_partial) + fetch_append(buf, sizeof(buf), &len, + "%sBODY[%s]<%u> {%u}\r\n", + need_sp ? " " : "", s->pending_body_label, + s->pending_body_partial_origin, + s->pending_body_len); + else + fetch_append(buf, sizeof(buf), &len, + "%sBODY[%s] {%u}\r\n", need_sp ? " " : "", + s->pending_body_label, s->pending_body_len); + session_write(s, buf, len); + if (s->pending_body_len > 0) + session_write(s, s->pending_body_buf, + s->pending_body_len); + } + session_write(s, ")\r\n", 3); + } else { + fetch_append(buf, sizeof(buf), &len, ")"); + + if (len >= sizeof(buf)) + log_warnx("session %u: FETCH response for seq %u " + "truncated", s->id, meta->seqno); + + session_untagged(s, buf); + } + + /* reset pending_*_found even when have_* is false, so it doesn't leak into the next message's response */ + if (have_header) { + free(s->pending_header_buf); + s->pending_header_buf = NULL; + s->pending_header_len = 0; + } + if (s->fetch_attrs & (MBOX_FETCH_BODY_HEADER | MBOX_FETCH_HEADER_FIELDS)) + s->pending_header_found = 0; + + if (have_body) { + free(s->pending_body_buf); + s->pending_body_buf = NULL; + s->pending_body_len = 0; + } + if (s->fetch_attrs & (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT | + MBOX_FETCH_BODY_PART)) + s->pending_body_found = 0; + + if (have_envelope) { + free(s->pending_envelope_buf); + s->pending_envelope_buf = NULL; + s->pending_envelope_len = 0; + } + if (s->fetch_attrs & MBOX_FETCH_ENVELOPE) + s->pending_envelope_found = 0; + + if (have_bodystructure) { + free(s->pending_bodystructure_buf); + s->pending_bodystructure_buf = NULL; + s->pending_bodystructure_len = 0; + } + if (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE) + s->pending_bodystructure_found = 0; +} + +/* STORE's untagged FETCH response (RFC 9051 SS6.4.6) always shows FLAGS; MODSEQ shown whenever CONDSTORE-aware (SS3.1.3) */ +void +session_send_store_fetch_response(struct session *s, + const struct imsg_mbox_fetch_meta *meta) +{ + char buf[MBOX_FLAGS_MAX + 96]; + size_t len; + + /* RFC 9051 SS6.4.9: a UID STORE's echo must include UID, right after FLAGS */ + len = (size_t)snprintf(buf, sizeof(buf), "%u FETCH (FLAGS (%s)", + meta->seqno, meta->flags); + if (s->cmd_by_uid && len < sizeof(buf)) + len += (size_t)snprintf(buf + len, sizeof(buf) - len, + " UID %u", meta->uid); + if (s->condstore_enabled && len < sizeof(buf)) + len += (size_t)snprintf(buf + len, sizeof(buf) - len, + " MODSEQ (%llu)", (unsigned long long)meta->modseq); + if (len < sizeof(buf)) + snprintf(buf + len, sizeof(buf) - len, ")"); + + session_untagged(s, buf); +} + +/* splits a trailing RFC 4466 modifier list off spec (shared by cmd_fetch()/cmd_store_cmd()); NUL-terminates spec in place */ +char * +split_trailing_modifiers(char *spec) +{ + char *p = spec; + + if (*p == '(') { + int depth = 0; + + for (;;) { + if (*p == '(') + depth++; + else if (*p == ')') { + depth--; + if (depth == 0) { + p++; + break; + } + } else if (*p == '\0') + return (NULL); /* unterminated; caller's own parser produces the BAD for this */ + p++; + } + } else { + while (*p != '\0' && *p != ' ') + p++; + } + + if (*p == '\0') + return (NULL); + *p++ = '\0'; + while (*p == ' ') + p++; + if (*p == '\0') + return (NULL); + return (p); +} + +/* FETCH's trailing fetch-modifier list (RFC 4466 + RFC 7162 SS3.1.4.1/SS3.2.6); *want_vanished lets fetch_dispatch() pair-check later */ +int +parse_fetch_modifiers(char *modspec, struct imsg_mbox_fetch *req, + const struct session *s, int by_uid, int *want_vanished, + const char **errmsg) +{ + char *p, *tok, *save; + size_t len; + + *errmsg = NULL; + *want_vanished = 0; + len = strlen(modspec); + if (len < 2 || modspec[0] != '(' || modspec[len - 1] != ')') { + *errmsg = "malformed fetch-modifier list"; + return (-1); + } + modspec[len - 1] = '\0'; + p = modspec + 1; + + for (tok = strtok_r(p, " ", &save); tok != NULL; + tok = strtok_r(NULL, " ", &save)) { + if (strcasecmp(tok, "CHANGEDSINCE") == 0) { + const char *valtok = strtok_r(NULL, " ", &save); + char *ep; + + if (valtok == NULL) { + *errmsg = "CHANGEDSINCE requires a " + "mod-sequence value"; + return (-1); + } + errno = 0; + req->changedsince = strtoull(valtok, &ep, 10); + if (*ep != '\0' || errno != 0) { + *errmsg = "invalid CHANGEDSINCE mod-sequence"; + return (-1); + } + req->has_changedsince = 1; + req->attrs |= MBOX_FETCH_MODSEQ; + } else if (strcasecmp(tok, "VANISHED") == 0) { + if (!by_uid) { + /* RFC 7162 SS3.2.6: VANISHED not allowed with plain FETCH, MUST return tagged BAD */ + *errmsg = "VANISHED is only valid as a UID " + "FETCH modifier (RFC 7162 SS3.2.6)"; + return (-1); + } + if (!s->qresync_enabled) { + *errmsg = "VANISHED requires ENABLE QRESYNC " + "first (RFC 7162 SS3.2.6)"; + return (-1); + } + *want_vanished = 1; + } else { + *errmsg = "unrecognized fetch modifier"; + return (-1); + } + } + + return (0); +} + +/* RFC 9051 SS6.4.5 fetch + RFC 4466/7162 modifier list; plain BODY[...] and BODY[] are a deliberate v1 scope cut */ +int +cmd_fetch(struct session *s, const char *tag, char *args) +{ + return fetch_dispatch(s, tag, args, 0); +} + +/* shared body for cmd_fetch() (by_uid=0) and cmd_uid()'s FETCH branch (by_uid=1); RFC 9051 SS6.4.9 forces MBOX_FETCH_UID into attrs */ +int +fetch_dispatch(struct session *s, const char *tag, char *args, int by_uid) +{ + struct imsg_mbox_fetch req; + const char *seqtok; + char *attspec, *modspec; + uint32_t lo, hi, attrs; + int lo_star, hi_star, rc, want_vanished = 0, degraded; + int header_fields_not = 0; + char header_fields[HEADER_FIELDS_MAX]; + char header_fields_label[HEADER_FIELDS_LABEL_MAX]; + int bodystructure_full = 0; + char section_part[SECTION_PART_MAX]; + int has_partial = 0; + uint32_t partial_start = 0, partial_count = 0; + const char *errmsg; + const char *cmdname = by_uid ? "UID FETCH" : "FETCH"; + + if (args == NULL) { + session_reply(s, tag, "BAD", + "FETCH requires a sequence set and message data item(s)"); + return (1); + } + + seqtok = args; + while (*args != '\0' && *args != ' ') + args++; + if (*args == '\0') { + session_reply(s, tag, "BAD", + "FETCH requires message data item(s)"); + return (1); + } + *args++ = '\0'; + while (*args == ' ') + args++; + attspec = args; + + if (strchr(seqtok, ',') != NULL) { + session_reply(s, tag, "BAD", + "comma-separated sequence sets not supported in v1 -- " + "issue separate FETCH commands"); + return (1); + } + + if (parse_seq_range(seqtok, &lo, &hi, &lo_star, &hi_star) == -1) { + session_reply(s, tag, "BAD", "invalid sequence set"); + return (1); + } + + modspec = split_trailing_modifiers(attspec); + + rc = parse_fetch_atts(attspec, &attrs, °raded, &header_fields_not, + header_fields, sizeof(header_fields), header_fields_label, + sizeof(header_fields_label), &bodystructure_full, section_part, + sizeof(section_part), &has_partial, &partial_start, + &partial_count, &errmsg); + if (rc == -1) { + session_reply(s, tag, "BAD", errmsg); + return (1); + } + if (rc == -2) { + session_reply(s, tag, "NO", errmsg); + return (1); + } + if (degraded) + log_debug("session %u: %s: one or more unsupported message " + "data items silently dropped (plain BODY[...]/BODY[]" + "/BODY.PEEK[] with MESSAGE/RFC822|GLOBAL or MULTIPART" + " nested numbering/RFC822[.HEADER/.TEXT]) -- answering " + "with whatever was recognized", s->id, cmdname); + + memset(&req, 0, sizeof(req)); + req.attrs = attrs; + req.by_uid = by_uid; + req.header_fields_not = header_fields_not; + + /* re-checked though already bounds-checked above; store.c applies has_partial/section_part to whichever of WHOLE/TEXT/PART wins */ + if (strlcpy(req.header_fields, header_fields, + sizeof(req.header_fields)) >= sizeof(req.header_fields) || + strlcpy(req.section_part, section_part, sizeof(req.section_part)) + >= sizeof(req.section_part)) { + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + req.has_partial = has_partial; + req.partial_start = partial_start; + req.partial_count = partial_count; + + /* verbatim client-typed label never crosses to store.c -- stashed here for session_send_fetch_response() to echo */ + if (attrs & MBOX_FETCH_BODY_HEADER) { + if (strlcpy(s->pending_header_label, "HEADER", + sizeof(s->pending_header_label)) >= + sizeof(s->pending_header_label)) { + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + } else if (attrs & MBOX_FETCH_HEADER_FIELDS) { + if (strlcpy(s->pending_header_label, header_fields_label, + sizeof(s->pending_header_label)) >= + sizeof(s->pending_header_label)) { + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + } + + /* same idea, for BODY.PEEK[]/[TEXT]/[]; WHOLE>TEXT>PART must match store.c's handle_mbox_fetch() */ + if (attrs & MBOX_FETCH_BODY_WHOLE) { + s->pending_body_label[0] = '\0'; + } else if (attrs & MBOX_FETCH_BODY_TEXT) { + if (strlcpy(s->pending_body_label, "TEXT", + sizeof(s->pending_body_label)) >= + sizeof(s->pending_body_label)) { + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + } else if (attrs & MBOX_FETCH_BODY_PART) { + if (strlcpy(s->pending_body_label, section_part, + sizeof(s->pending_body_label)) >= + sizeof(s->pending_body_label)) { + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + } + s->pending_body_has_partial = has_partial; + s->pending_body_partial_origin = partial_start; + + /* same idea, for BODYSTRUCTURE: response label echoes whichever bare token ("BODY"/"BODYSTRUCTURE") the client used */ + if (attrs & MBOX_FETCH_BODYSTRUCTURE) { + if (strlcpy(s->pending_bodystructure_label, + bodystructure_full ? "BODYSTRUCTURE" : "BODY", + sizeof(s->pending_bodystructure_label)) >= + sizeof(s->pending_bodystructure_label)) { + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + } + + if (modspec != NULL) { + if (parse_fetch_modifiers(modspec, &req, s, by_uid, + &want_vanished, &errmsg) == -1) { + session_reply(s, tag, "BAD", errmsg); + return (1); + } + } + + if (want_vanished && !req.has_changedsince) { + /* RFC 7162 SS3.2.6: VANISHED MUST be paired with CHANGEDSINCE, else tagged BAD */ + session_reply(s, tag, "BAD", + "VANISHED requires CHANGEDSINCE also be specified " + "(RFC 7162 SS3.2.6)"); + return (1); + } + req.want_vanished = want_vanished; + + if (by_uid) + req.attrs |= MBOX_FETCH_UID; + + if (s->store_iev == NULL) { + /* same invariant check as cmd_select() -- ST_SELECTED requires store_iev already wired */ + log_warnx("session %u: %s with no store channel wired", + s->id, cmdname); + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + + req.seq_lo = lo; + req.seq_hi = hi; + req.lo_is_star = lo_star; + req.hi_is_star = hi_star; + + /* RFC 7162 SS3.1: MODSEQ fetch-att and CHANGEDSINCE modifier are both CONDSTORE-enabling */ + if (req.attrs & MBOX_FETCH_MODSEQ) + session_condstore_enable(s); + + if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >= + sizeof(s->pending_tag)) { + session_reply(s, tag, "NO", "[SERVERBUG] internal error"); + return (1); + } + s->fetch_attrs = req.attrs; + s->cmd_by_uid = by_uid; + s->state = SESSION_FETCHING; + + if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_FETCH, 0, 0, -1, + &req, sizeof(req)) == -1) + log_warn("session %u: imsg_compose IMSG_MBOX_FETCH", s->id); + imsgev_add(s->store_iev); + + return (1); +} blob - a42a003990cf0f34ffcf910cd350f587df7783e9 blob + b418cd4079c0ac53d846b137d7487da81c37c0d0 --- src/listener.c +++ src/listener.c @@ -14,113 +14,7 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* - * listener.c -- protocol/network process. Implements the "listener" - * section of openimap-privsep-design.md: owns client TCP sockets, runs - * the IMAP command parser/dispatch table for the "any state" and "not - * authenticated state" command sets, and now terminates TLS for real - * (implicit-TLS port 993 per RFC 8314, and STARTTLS on the cleartext - * port) via libtls -- tls_server()/tls_configure() built once at boot - * from cert/key bytes parent sends (IMSG_TLS_CERT/IMSG_TLS_KEY), then - * tls_accept_socket()+tls_handshake() per connection, sourced directly - * against src/lib/libtls/tls.h and httpd's server_tls_init()/server_ - * tls_handshake() (openbsd_source/src/usr.sbin/httpd/server.c). AUTHENTICATE - * PLAIN is now real too: cmd_authenticate() handles both the inline- - * initial-response and continuation-request forms (RFC 9051 SS6.2.2), - * decodes via b64_pton() (, sourced against openbsd_source/src/ - * lib/libc/net/base64.c), splits per RFC 4616's authzid/authcid/passwd - * framing (fetched directly from https://www.rfc-editor.org/rfc/rfc4616.txt - * this session -- not present in research/ or openbsd_source/), and sends - * IMSG_AUTH_REQUEST to auth over iev_auth; the tagged OK/NO back to the - * client is sent from whichever of listener_dispatch_auth()'s IMSG_AUTH_ - * RESULT or listener_dispatch_parent()'s IMSG_SETUP_PEER/IMSG_STORE_FORK - * cases actually resolves the async round trip. A session can now - * genuinely reach SESSION_AUTHENTICATED. imap_cmds[] also now has a real, - * correctly state-gated entry for every command-auth (RFC 9051 SS6.3) and - * command-select (SS6.4) command -- ENABLE, SELECT, LIST, FETCH, STORE, - * EXPUNGE, CLOSE, UNSELECT, APPEND, and SEARCH are fully implemented - * (cmd_enable()/cmd_select()/cmd_list()/cmd_fetch()/cmd_store_cmd()/cmd_ - * expunge()/cmd_close()/cmd_unselect()/cmd_append()/cmd_search(), FETCH - * message-metadata-only: FLAGS/UID/INTERNALDATE/RFC822.SIZE, not BODY[] - * -- see cmd_fetch()'s own comment for why). STORE reuses FETCH's - * IMSG_MBOX_FETCH_META/IMSG_MBOX_RESULT reply pair, since RFC 9051 - * SS6.4.6 says STORE's only response is itself an untagged FETCH; CLOSE - * reuses EXPUNGE's IMSG_MBOX_EXPUNGE/IMSG_MBOX_EXPUNGED/IMSG_MBOX_RESULT - * wholesale (silent=1, no untagged EXPUNGE responses, and a different - * post-completion state -- see session_request_expunge()/session_handle_ - * mbox_result()); UNSELECT needs no store round trip at all, since - * store.c holds no per-selection state to free. LIST (SS6.3.9, basic - * syntax only -- extended selection/return options get a flagged NO) - * needs no store round trip either: v1 has no CREATE, so INBOX's - * existence is never in question, making LIST pure string/wildcard - * matching against the fixed name "INBOX" (list_pattern_match()). - * APPEND (SS6.3.12) required this file's first real IMAP literal - * ({n}/{n+}) support -- a raw-byte read phase (s->literal_pending, in - * session_dispatch_client()'s read loop, intercepted before the CRLF - * line parser since literal bytes can contain embedded CRLFs) followed - * by a store round trip in the new SESSION_APPENDING state; message - * bytes travel to store.c as variable-length trailing data on a single - * imsg (imsg_get_buf()/imsg_get_len(), verified against the real imsg- - * buffer.c this session), capped at APPEND_LITERAL_MAX (12000 bytes) to - * stay under imsg's MAX_IMSGSIZE -- larger messages need real fd-passing, - * not implemented this pass, same deferral as BODY[] FETCH. SEARCH - * (SS6.4.4, ESEARCH responses per SS7.3.4) compiles the flag/date/size/ - * sequence-number/UID-range/NOT/OR/parenthesized-list search-key grammar - * into a flat postfix bytecode (parse_search_key()/parse_search_key_ - * list()) sent to store.c as variable-length trailing data on IMSG_MBOX_ - * SEARCH (the same imsg technique APPEND established), which streams - * back matching sequence numbers via IMSG_MBOX_SEARCH_MATCH for session_ - * finish_search() to assemble into MIN/MAX/ALL/COUNT per RFC 9051's own - * worked examples; content-and-header-based search keys (BCC/BODY/CC/ - * FROM/HEADER/SENTBEFORE/SENTON/SENTSINCE/SUBJECT/TEXT/TO), the SAVE/"$" - * result variable, and the "UID SEARCH" command wrapper are all - * deliberately out of scope this pass -- see imapd.h's imsg_mbox_ - * search comment. Everything else in those two command sets still - * replies NO via stub_not_implemented() pending store.c's still- - * undesigned IMSG_MBOX_* wire protocol for that operation. A session can - * now genuinely reach SESSION_SELECTED, issue a LIST from either - * Authenticated or Selected, and round-trip a FETCH, STORE, EXPUNGE, - * CLOSE, UNSELECT, APPEND, or SEARCH. - * - * The boot-time setup handshake, receiving the listening socket fds + - * TLS cert/key from parent, the per-session table, requesting + wiring - * a per-session store child on successful auth (IMSG_STORE_FORK / - * IMSG_SETUP_PEER / IMSG_SETUP_DONE from parent, demuxed by session_id), - * its own privilege drop, and full session teardown (including - * notifying a wired store child via IMSG_STORE_SHUTDOWN, and closing - * a TLS session correctly -- see session_teardown()'s comment on why - * tls_close() sometimes needs a manual close(2) fallback and sometimes - * doesn't) are all implemented for real too. - * - * Two distinct persistent channels exist here, fixed this pass (see the - * "channel-identity gap" note that used to be here as a flagged TODO): - * - iev_auth: the boot-time SETUP_PEER-wired channel to the AUTH - * process (peer_fd below). Carries IMSG_AUTH_REQUEST out / - * IMSG_AUTH_RESULT in. - * - iev_parent: this process's own fd-3 channel back to PARENT, kept - * alive for the process's whole lifetime (parent never closes its - * end -- "parent isn't on this path once wiring completes" only - * applies to STORE, not to listener/auth themselves). Carries - * IMSG_STORE_FORK out, and IMSG_SETUP_PEER (a new store child's - * peer fd, demuxed by session_id via imsg_get_id()) / IMSG_STORE_FORK - * (parent replying with a *failure*) in. - * - * An earlier draft of this file used ONE struct imsgev (also named - * iev_parent) fed from peer_fd -- i.e. it was actually the AUTH channel - * despite the name -- and never turned fd 3 itself into a persistent, - * event-driven channel at all past the synchronous boot-time drain loop. - * That meant IMSG_STORE_FORK was being sent to auth, not parent, and - * nothing ever read fd 3 again after boot. Fixed below by keeping fd 3's - * already-populated struct imsgbuf alive across the transition into the - * event loop (imsgev_init_from_ibuf() in imsgev.c) instead of a second, - * fresh imsgbuf_init() on the same fd, which would have silently dropped - * any bytes already buffered from parent (parent doesn't wait for acks - * between sends, so more than one message can already be in flight by - * the time this process gets around to reading it). - * - * API NAMES: checked against the real src/imsg.h this session -- see - * parent.c's header comment for the full verification note. - */ +/* listener.c -- protocol/network process: client sockets, IMAP dispatch, TLS. */ #include #include @@ -128,14 +22,7 @@ #include -/* - * (below, for b64_pton()) uses "struct sockaddr_in", - * "struct in_addr", and "struct in6_addr" without defining them itself -- - * confirmed against openbsd_source/src/include/resolv.h, which only - * includes , , and . - * must come first, matching the ordering smtpd's util.c and httpd's - * server_http.c both use for the same pairing. - */ +/* must precede , which needs struct sockaddr_in. */ #include #include #include @@ -155,1214 +42,33 @@ #include "imapd.h" #include "log.h" - -enum session_state { - SESSION_NOT_AUTH, - SESSION_AUTHENTICATING, /* IMSG_AUTH_REQUEST sent, awaiting reply */ - SESSION_STORE_PENDING, /* IMSG_STORE_FORK sent, awaiting peer */ - SESSION_AUTHENTICATED, - SESSION_SELECTING, /* IMSG_MBOX_SELECT sent, awaiting reply -- - * see cmd_select()/session_store_dispatch() */ - SESSION_SELECTED, - SESSION_FETCHING, /* IMSG_MBOX_FETCH sent, awaiting the - * IMSG_MBOX_FETCH_META stream + terminal - * IMSG_MBOX_RESULT -- see cmd_fetch()/ - * session_handle_mbox_result() */ - SESSION_STORING, /* IMSG_MBOX_STORE sent, awaiting the same - * IMSG_MBOX_FETCH_META stream + terminal - * IMSG_MBOX_RESULT shape SESSION_FETCHING - * uses -- see cmd_store_cmd()'s comment on - * why STORE reuses FETCH's reply types */ - SESSION_EXPUNGING, /* IMSG_MBOX_EXPUNGE sent (by EXPUNGE itself, - * or by CLOSE with silent=1), awaiting the - * IMSG_MBOX_EXPUNGED stream + terminal - * IMSG_MBOX_RESULT -- see cmd_expunge()/ - * cmd_close()/session_handle_mbox_result() */ - SESSION_APPENDING, /* IMSG_MBOX_APPEND sent, awaiting the single - * terminal IMSG_MBOX_APPENDED reply -- see - * cmd_append()/session_finish_append()/ - * session_handle_mbox_appended(). Distinct - * from the *client-literal-read* phase that - * precedes it (s->literal_pending -- see that - * field's comment): this state only covers - * the store round trip, once the full - * message has already been read off the - * wire. */ - SESSION_SEARCHING, /* IMSG_MBOX_SEARCH sent, awaiting the - * IMSG_MBOX_SEARCH_MATCH stream + terminal - * IMSG_MBOX_RESULT -- see cmd_search()/ - * session_finish_search(). Same per-match- - * stream-then-terminal-result reply shape as - * SESSION_FETCHING/STORING/EXPUNGING, so it's - * handled by the same session_handle_mbox_ - * result() entry point, which branches to - * session_finish_search() first. */ - SESSION_STATUSING, /* IMSG_MBOX_STATUS sent, awaiting the single - * terminal IMSG_MBOX_STATUS_RESULT reply -- - * see cmd_status()/session_handle_mbox_ - * status_result(). Same single-request/single- - * reply shape as SESSION_SELECTING, but - * (unlike SELECT) never changes s->state's - * SELECTED-ness -- STATUS "does not change the - * currently selected mailbox" (RFC 9051 - * SS6.3.11) -- so s->status_prev_state records - * whichever ST_AUTH state was current when - * cmd_status() was called, for session_handle_ - * mbox_status_result() to restore. */ - SESSION_COPYING, /* IMSG_MBOX_COPY or IMSG_MBOX_MOVE sent - * (s->cmd_is_move says which), awaiting the - * IMSG_MBOX_COPY_MAPPING stream (plus, for a - * MOVE, an interleaved IMSG_MBOX_EXPUNGED - * stream -- see s->move_expunged's comment) + - * terminal IMSG_MBOX_RESULT -- see cmd_copy()/ - * cmd_move()/session_finish_copy_or_move(). - * Same per-message-stream-then-terminal-result - * shape as SESSION_FETCHING/STORING/EXPUNGING/ - * SEARCHING, so it's handled by the same - * session_handle_mbox_result() entry point, - * which branches to session_finish_copy_or_ - * move() first, the same way it already - * branches to session_finish_search(). */ - - /* - * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 additions (flat multi-mailbox - * support, docs/openimap-storage-backend.md item 10). All four are - * command-auth (valid in Authenticated or Selected state) and never - * change s->state's SELECTED-ness -- same "record whichever ST_AUTH - * state was current before, restore it after" pattern SESSION_ - * STATUSING's s->status_prev_state already established, reused here - * as s->mbox_op_prev_state (one shared field: only one of these four - * can ever be in flight for a given session at once, so there's no - * need for four separate fields the way SESSION_APPENDING has its - * own append_prev_state). - */ - SESSION_CREATING, /* IMSG_MBOX_CREATE sent, awaiting the single - * terminal IMSG_MBOX_RESULT reply -- see - * cmd_create()/session_finish_mbox_op(). */ - SESSION_DELETING, /* IMSG_MBOX_DELETE sent, same single-terminal- - * reply shape as SESSION_CREATING -- see - * cmd_delete()/session_finish_mbox_op(). */ - SESSION_RENAMING, /* IMSG_MBOX_RENAME sent, same single-terminal- - * reply shape as SESSION_CREATING -- see - * cmd_rename()/session_finish_mbox_op(). */ - SESSION_LISTING /* IMSG_MBOX_LIST sent, awaiting the - * IMSG_MBOX_LIST_ITEM stream + terminal - * IMSG_MBOX_RESULT -- see list_dispatch()/ - * session_finish_list(). Same per-item-stream- - * then-terminal-result shape as SESSION_ - * SEARCHING/COPYING, so it's handled by the - * same session_handle_mbox_result() entry - * point too, branching to session_finish_ - * list() first. */ -}; - -/* - * Generous line-length cap for the raw CRLF-delimited read buffer below. - * RFC 9051 doesn't mandate a specific limit -- SS7.1.3's "* BAD Command - * line too long" is example text, not a normative value -- but any real - * server needs one to bound memory for a client that never sends CRLF. - * Revisit once IMAP literals ({n}-prefixed octet counts, SS4.3) are - * implemented: those need a different, larger mechanism than a flat line - * buffer, since a literal's byte count is part of the command syntax - * itself, not just "a longer line". - */ -#define SESSION_INBUF_MAX 8192 - -/* - * Generous bound for a client-chosen tag we need to remember across an - * async IMSG_AUTH_REQUEST/IMSG_AUTH_RESULT (and, on success, IMSG_STORE_ - * FORK/IMSG_SETUP_PEER) round trip -- RFC 9051 SS9 doesn't specify a tag - * length limit (`tag = 1*`), so this is the - * same kind of deliberate, flagged simplification as session_reply()'s - * 512-byte response buffer: truncated via strlcpy() rather than rejected, - * matching this file's existing truncate-rather-than-overflow style. - */ -#define IMAP_TAG_MAX 64 - -/* - * Cap on the verbatim label text (e.g. "HEADER.FIELDS (DATE FROM)" or - * "HEADER.FIELDS.NOT (X-SPAM-STATUS)") stashed on struct session's - * pending_header_label and echoed back into the FETCH response's - * "BODY[