nio

a simple irc client
git clone git@git.kloet.net/nio.git
Download | Log | Files | Refs | README

commit e75573bcace04cb89b5089596089ac3f4b1038b6
parent f34117050041a0b5dc7e918a90e0aa6726c4c38d
Author: Andrew Kloet <andrew@kloet.net>
Date:   Sat, 28 Mar 2026 18:29:31 -0400

add SASL authentication, refactor scmd state machine

Diffstat:
Mnio.1 | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mnio.c | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
2 files changed, 220 insertions(+), 77 deletions(-)

diff --git a/nio.1 b/nio.1 @@ -1,4 +1,4 @@ -.Dd March 27, 2026 +.Dd March 28, 2026 .Dt NIO 1 .Os .Sh NAME @@ -13,30 +13,56 @@ .Op Fl p Ar port .Op Fl s Ar server .Op Fl u Ar user +.Op Fl c Ar certfile . .Sh DESCRIPTION .Nm -is a multiplexing curses interface for IRC featuring: -.Bl -bullet -compact -.It -Infinite scrollback; -.It -Automatic reconnection; -.It -UTF-8 support (inputting is still to do); -.It -Line editing (emacs like keybindings); -.It -Activity markers -.It -Logging -.It -Terminal resizes (inside an xterm). +is a multiplexing curses interface for IRC featuring SASL authentication, +infinite scrollback, and automatic reconnection. +. +.Ss Options +.Bl -tag -width Ds +.It Fl c Ar certfile +Use +.Ar certfile +as a TLS client certificate. +This is used for both establishing the encrypted connection and for SASL +EXTERNAL (CertFP) authentication. +.It Fl l Ar logfile +Append all IRC activity, including timestamps and channel names, to +.Ar logfile . +.It Fl n Ar nick +Set the IRC nickname. +If not provided, +.Nm +will check the +.Ev IRCNICK +environment variable, then the +.Ev USER +variable. +.It Fl p Ar port +Connect to the server on +.Ar port . +Defaults to 6697. +.It Fl s Ar server +Connect to the IRC server at +.Ar server . +Defaults to irc.oftc.net. +.It Fl T +Disable SSL/TLS and connect using a plaintext socket. +.It Fl u Ar user +Set the IRC username (ident). +Defaults to the value of the +.Ev USER +environment variable, or "anonymous". .El . .Sh COMMANDS .Ss Chat Commands Chat commands are submitted with a newline. +They are not prepended with a '/'. +Any text not matching a command is sent as a message to the currently +focused channel. .Bl -tag -width Ds .It Ic j Ar #channel Join a given channel @@ -45,7 +71,7 @@ Leave the given channel(s), defaults to current channel. .It Ic m Ar recipient message Sends a private message. .It Ic r Ar message -Sends a raw message to the server. +Sends a raw IRC message to the server. .It Ic q Quits .Nm . @@ -95,12 +121,36 @@ The default username to use. .It Ev IRCNICK The default nickname to use. .It Ev IRCPASS -The default password to use. +The default password to used for SASL PLAIN authentication. +This is used if no certificate is provided via +.Fl c . .El . .Sh EXIT STATUS .Nm exits 0 if requested by the user and >0 if any other error occurs. +.Sh EXAMPLES +.Ss Using CertFP +.Bl -enum +.It +Generate a new TLS client certificate: +.Bd -literal +$ openssl req -x509 -newkey rsa:4096 -nodes -days 365 \\ + -keyout user.pem -out user.pem -subj "/CN=CertFP" +.Ed +.It +Connect to the server using the certificate: +.Bd -literal +$ nio -c user.pem +.Ed +.It +Identify with the server normally then add the certificate fingerprint to +your account: +.Bd -literal +m nickserv cert add +.Ed +.El +. .Sh AUTHORS .An Quentin Carbonneaux Aq Mt qcarbonneaux@gmail.com .An Hiltjo Posthuma Aq Mt hiltjo@codemadness.org diff --git a/nio.c b/nio.c @@ -1,4 +1,5 @@ #include <assert.h> +#include <ctype.h> #include <limits.h> #include <signal.h> #include <stdio.h> @@ -33,6 +34,24 @@ #define SRV "irc.oftc.net" #define PORT "6697" +typedef enum { + CMD_UNKNOWN, PRIVMSG, PING, PONG, PART, JOIN, QUIT, + CAP, AUTHENTICATE, SASL_OK, SASL_ERR, ERR_NICKNAMEINUSE = 433, + ERR_FORWARD = 470, ERR_FULL = 471, ERR_INVITE = 473, + ERR_BANNED = 474, ERR_BADKEY = 475 +} IrcCmd; + +struct { + const char *name; + IrcCmd id; +} cmd_map[] = { + { "PRIVMSG", PRIVMSG }, { "PING", PING }, { "PONG", PONG }, + { "PART", PART }, { "JOIN", JOIN }, { "QUIT", QUIT }, { "CAP", CAP }, + { "AUTHENTICATE", AUTHENTICATE }, { "903", SASL_OK }, { "904", SASL_ERR }, + { "433", ERR_NICKNAMEINUSE }, { "470", ERR_FORWARD }, { "471", ERR_FULL }, + { "473", ERR_INVITE }, { "474", ERR_BANNED }, { "475", ERR_BADKEY } +}; + enum { ChanLen = 64, LineLen = 512, @@ -70,6 +89,8 @@ static struct { SSL_CTX *ctx; } srv; static char nick[64]; +static char cert[PATH_MAX]; +static char key[512]; static int quit, winchg; static int nch, ch; /* Current number of channels, and current channel. */ static char outb[BufSz], *outp = outb; /* Output buffer. */ @@ -158,6 +179,30 @@ utf8encode(Rune u, char *c) return len; } +static int +is_empty(const char *str) { + return (str == NULL || str[0] == '\0'); +} + +static char * +b64_enc(const unsigned char *in, size_t len) +{ + static char out[BufSz]; + const char *tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + char *p = out; + size_t i; + + for (i = 0; i < len; i += 3) { + unsigned int v = in[i] << 16 | (i+1 < len ? in[i+1] << 8 : 0) | (i+2 < len ? in[i+2] : 0); + *p++ = tab[(v >> 18) & 0x3F]; + *p++ = tab[(v >> 12) & 0x3F]; + *p++ = (i+1 < len) ? tab[(v >> 6) & 0x3F] : '='; + *p++ = (i+2 < len) ? tab[v & 0x3F] : '='; + } + *p = '\0'; + return out; +} + static void sndf(const char *fmt, ...) { @@ -218,13 +263,11 @@ srd(void) } static void -sinit(const char *key, const char *nick, const char *user) +sinit(const char *nick, const char *user) { - if (key) - sndf("PASS %s", key); + sndf("CAP LS 302"); sndf("NICK %s", nick); sndf("USER %s 8 * :%s", user, user); - sndf("MODE %s +i", nick); } static char * @@ -248,6 +291,7 @@ dial(const char *host, const char *service) } break; } + freeaddrinfo(res); if (fd == -1) return "Cannot connect to host."; srv.fd = fd; @@ -258,11 +302,20 @@ dial(const char *host, const char *service) if (!srv.ctx) return "Could not initialize ssl context."; srv.ssl = SSL_new(srv.ctx); + if (!is_empty(cert)) { + if (access(cert, R_OK) != 0) + return "Certificate file does not exist or permission denied"; + if (SSL_use_certificate_chain_file(srv.ssl, cert) <= 0) + return "Failed to load certificate chain from file"; + if (SSL_use_PrivateKey_file(srv.ssl, cert, SSL_FILETYPE_PEM) <= 0) + return "Failed to load private key from file"; + if (!SSL_check_private_key(srv.ssl)) + return "Private key does not match the certificate"; + } if (SSL_set_fd(srv.ssl, srv.fd) == 0 || SSL_connect(srv.ssl) != 1) return "Could not connect with ssl."; } - freeaddrinfo(res); return 0; } @@ -429,8 +482,17 @@ pushf(int cn, const char *fmt, ...) static void scmd(char *usr, char *cmd, char *par, char *data) { - int s, c; - char *pm = strtok(par, " "), *chan; + IrcCmd type = CMD_UNKNOWN; + int c; + char *pm = strtok(par, " "); + char *chan; + + for (int i = 0; i < sizeof(cmd_map)/sizeof(cmd_map[0]); i++) { + if (!strcmp(cmd, cmd_map[i].name)) { + type = cmd_map[i].id; + break; + } + } if (!usr) usr = "?"; @@ -439,19 +501,13 @@ scmd(char *usr, char *cmd, char *par, char *data) if (bang) *bang = 0; } - if (!strcmp(cmd, "PRIVMSG")) { - if (!pm || !data) - return; - if (strchr("&#!+.~", pm[0])) - chan = pm; - else - chan = usr; - if (!(c = chfind(chan))) { - if (chadd(chan, 0) < 0) - return; - tredraw(); - } - c = chfind(chan); + + switch (type) { + case PRIVMSG: + if (!pm || !data) break; + chan = strchr("&#!+.~", pm[0]) ? pm : usr; + if (!(c = chfind(chan)) && (c = chadd(chan, 0)) < 0) + break; if (strcasestr(data, nick)) { pushf(c, PFMTHIGH, usr, data); chl[c].high |= ch != c; @@ -461,40 +517,74 @@ scmd(char *usr, char *cmd, char *par, char *data) chl[c].new = 1; tdrawbar(); } - } else if (!strcmp(cmd, "PING")) { + break; + case PING: sndf("PONG :%s", data ? data : "(null)"); - } else if (!strcmp(cmd, "PONG")) { - /* nothing */ - } else if (!strcmp(cmd, "PART")) { - if (!pm) - return; - pushf(chfind(pm), "-!- %s has left %s", usr, pm); - } else if (!strcmp(cmd, "JOIN")) { - if (!pm) - return; - pushf(chfind(pm), "-!- %s has joined %s", usr, pm); - } else if (!strcmp(cmd, "470")) { /* Channel forwarding. */ - char *ch = strtok(0, " "), *fch = strtok(0, " "); - - if (!ch || !fch || !(s = chfind(ch))) - return; - chl[s].name[0] = 0; - strncat(chl[s].name, fch, ChanLen - 1); - tdrawbar(); - } else if (!strcmp(cmd, "471") || !strcmp(cmd, "473") - || !strcmp(cmd, "474") || !strcmp(cmd, "475")) { /* Join error. */ - if ((pm = strtok(0, " "))) { - chdel(pm); - pushf(0, "-!- Cannot join channel %s (%s)", pm, cmd); - tredraw(); - } - } else if (!strcmp(cmd, "QUIT")) { /* Commands we don't care about. */ - return; - } else if (!strcmp(cmd, "NOTICE") || !strcmp(cmd, "375") - || !strcmp(cmd, "372") || !strcmp(cmd, "376")) { - pushf(0, "%s", data ? data : ""); - } else - pushf(0, "%s - %s %s", cmd, par, data ? data : "(null)"); + break; + case PONG: + break; + case JOIN: + case PART: + if (pm) pushf(chfind(pm), "-!- %s has %s %s", + usr, (type == JOIN ? "joined" : "left"), pm); + break; + case QUIT: + break; + case ERR_FORWARD: { + char *oldch = strtok(NULL, " "); + char *newch = strtok(NULL, " "); + if (!oldch || !newch || !(c = chfind(oldch))) + break; + chl[c].name[0] = 0; + strncat(chl[c].name, newch, ChanLen - 1); + tdrawbar(); + break; + } + case ERR_FULL: + case ERR_INVITE: + case ERR_BANNED: + case ERR_BADKEY: + if (!(pm = strtok(NULL, " "))) + break; + chdel(pm); + pushf(0, "-!- Cannot join %s (%s)", pm, cmd); + tredraw(); + break; + case ERR_NICKNAMEINUSE: + strncat(nick, "_", sizeof(nick) - strlen(nick) - 1); + sndf("NICK %s", nick); + break; + case CAP: + if (!(pm = strtok(NULL, " "))) break; + if (!strcmp(pm, "LS")) + sndf((!is_empty(key) || !is_empty(cert)) && data && strstr(data, "sasl") ? "CAP REQ :sasl" : "CAP END"); + else if (!strcmp(pm, "ACK") && data && strstr(data, "sasl")) + sndf(is_empty(cert) ? "AUTHENTICATE PLAIN" : "AUTHENTICATE EXTERNAL"); + else if (!strcmp(pm, "NAK") || !strcmp(pm, "ACK")) + sndf("CAP END"); + break; + case AUTHENTICATE: + if (!par || *par != '+') break; + if (!is_empty(cert)) { + sndf("AUTHENTICATE +"); + } else { + char raw[512]; + int r = snprintf(raw, sizeof(raw), "%s%c%s%c%s", nick, 0, nick, 0, key); + sndf("AUTHENTICATE %s", b64_enc((unsigned char *)raw, r)); + } + break; + case SASL_OK: + case SASL_ERR: + sndf("CAP END"); + pushf(0, "-!- SASL auth %s", (type == SASL_OK ? "successful" : "failed!")); + break; + default: + if (isdigit(cmd[0]) || !strcmp(cmd, "NOTICE")) + pushf(0, "%s", data ? data : ""); + else + pushf(0, "%s - %s %s", cmd, par, data ? data : "(null)"); + break; + } } static void @@ -811,19 +901,19 @@ main(int argc, char *argv[]) { const char *user = getenv("USER"); const char *ircnick = getenv("IRCNICK"); - const char *key = getenv("IRCPASS"); + snprintf(key, sizeof(key), "%s", getenv("IRCPASS")); const char *server = SRV; const char *port = PORT; char *err; int o, reconn, ping; signal(SIGPIPE, SIG_IGN); - while ((o = getopt(argc, argv, "thk:n:u:s:p:l:")) >= 0) + while ((o = getopt(argc, argv, "hT:n:c:u:s:p:l:")) >= 0) switch (o) { case 'h': case '?': usage: - fputs("usage: nio [-n NICK] [-u USER] [-s SERVER] [-p PORT] [-l LOGFILE ] [-t] [-h]\n", stderr); + fputs("usage: nio [-n NICK] [-u USER] [-s SERVER] [-p PORT] [-l LOGFILE ] [-c certficiate] [-hT]\n", stderr); exit(0); case 'l': if (!(logfp = fopen(optarg, "a"))) @@ -846,6 +936,9 @@ main(int argc, char *argv[]) case 'p': port = optarg; break; + case 'c': + snprintf(cert, sizeof(cert), "%s", optarg); + break; } if (!user) user = "anonymous"; @@ -860,7 +953,7 @@ main(int argc, char *argv[]) if (err) panic(err); chadd(server, 0); - sinit(key, nick, user); + sinit(nick, user); reconn = 0; ping = 0; while (!quit) { @@ -892,7 +985,7 @@ main(int argc, char *argv[]) pushf(0, "-!- Link lost, attempting reconnection..."); if (dial(server, port) != 0) continue; - sinit(key, nick, user); + sinit(nick, user); for (c = chl; c < &chl[nch]; ++c) if (c->join) sndf("JOIN %s", c->name);