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:
| M | nio.1 | | | 88 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------- |
| M | nio.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);