Commit Diff


commit - /dev/null
commit + d33ca522cdea5d420643c2c92a67db48764a0fc4
blob - /dev/null
blob + 3a6e8b483b30f62b49005802d2f385998ec37933 (mode 644)
--- /dev/null
+++ Makefile
@@ -0,0 +1,14 @@
+PROG=	ochat
+SRCS=	ochat.c proto.c
+NOMAN=	yes
+
+CFLAGS+= -Wall -Wextra -Wpointer-arith -Wshadow -Wmissing-prototypes
+LDADD= -lcrypto -lutil
+DPADD= ${LIBCRYPTO} ${LIBUTIL}
+
+BINDIR= /usr/local/bin
+BINOWN= nobody
+BINGRP= nobody
+BINMODE=6555
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + 2cf1a309829ca5ed8bb8eb9ac2f138fc5f878f97 (mode 644)
--- /dev/null
+++ ochat.c
@@ -0,0 +1,471 @@
+/* ochat.c */
+
+/*
+ * ochat: minimal box-to-box encrypted chat, IPv4 only.
+ *
+ * Both sides run "ochat peer-address". Each tries to connect once:
+ * if the peer is not up yet, it listens instead and accepts only the
+ * peer. One process, one TCP connection, no files, no logs. Pledges
+ * down to "stdio tty" before any byte from the peer is parsed.
+ *
+ * Always runs as nobody, whoever starts it: install it setuid and
+ * setgid nobody (mode 6555), and the very first thing it does is
+ * set real, effective and saved ids to nobody. Started as root, it
+ * also chroots to /var/empty and drops supplementary groups.
+ */
+
+#include <sys/types.h>
+#include <sys/mman.h>
+#include <sys/resource.h>
+#include <sys/socket.h>
+
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <arpa/inet.h>
+
+#include <err.h>
+#include <errno.h>
+#include <poll.h>
+#include <pwd.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+#include <vis.h>
+
+#include "ochat.h"
+#include "proto.h"
+
+#define PORT 7171
+
+static struct termios tio_saved;
+static int tio_set;
+static uint8_t *pass; /* concealed, PASSMAX bytes */
+
+static void cleanup(void);
+static void sighandler(int);
+static void droppriv(void);
+static int netconnect(const struct sockaddr_in *);
+static int netaccept(const struct sockaddr_in *);
+static size_t readpass(void);
+static void tty_restore(void);
+static void sendframe(int, uint8_t, const uint8_t *, size_t);
+static void printmsg(const uint8_t *, size_t);
+__dead static void chat(int);
+
+__dead static void
+usage(void)
+{
+        fprintf(stderr, "usage: %s [-p port] address\n", getprogname());
+        exit(1);
+}
+
+int
+main(int argc, char *argv[])
+{
+        struct sockaddr_in peer;
+        struct rlimit rl = { 0, 0 };
+        char sas[SASLEN];
+        const char *errstr;
+        int ch, init, on = 1, port = PORT, s;
+        size_t passlen;
+
+        droppriv();
+
+        while ((ch = getopt(argc, argv, "lp:")) != -1) {
+                switch (ch) {
+                case 'p':
+                        port = strtonum(optarg, 1, 65535, &errstr);
+                        if (errstr != NULL)
+                                errx(1, "port %s: %s", optarg, errstr);
+                        break;
+                default:
+                        usage();
+                }
+        }
+
+        argc -= optind;
+        argv += optind;
+        if (argc != 1)
+                usage();
+
+        memset(&peer, 0, sizeof(peer));
+        peer.sin_len = sizeof(peer);
+        peer.sin_family = AF_INET;
+        peer.sin_port = htons(port);
+
+        if (inet_pton(AF_INET, argv[0], &peer.sin_addr) != 1)
+                errx(1, "%s: not an IPv4 address", argv[0]);
+
+        if (setrlimit(RLIMIT_CORE, &rl) == -1)
+                err(1, "setrlimit");
+
+        pass = secalloc(PASSMAX);
+        proto_init();
+        if (atexit(cleanup) == -1)
+                err(1, "atexit");
+
+        signal(SIGINT, sighandler);
+        signal(SIGTERM, sighandler);
+        signal(SIGHUP, sighandler);
+        signal(SIGPIPE, SIG_IGN);
+
+        if (pledge("stdio inet tty", NULL) == -1)
+                err(1, "pledge");
+
+        if ((passlen = readpass()) == 0)
+                warnx("empty passphrase: only the SAS authenticates the peer");
+
+        /* Whoever connects initiates the handshake. */
+        if ((s = netconnect(&peer)) != -1)
+                init = 1;
+        else {
+                s = netaccept(&peer);
+                init = 0;
+        }
+
+        if (setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)) == -1)
+                err(1, "setsockopt");
+        fprintf(stderr, "connected to %s\n", argv[0]);
+
+        if (pledge("stdio tty", NULL) == -1)
+                err(1, "pledge");
+
+        proto_handshake(s, init, pass, passlen, sas, sizeof(sas));
+        explicit_bzero(pass, PASSMAX);
+
+        fprintf(stderr, "secure channel up, SAS %s\n"
+                "compare the SAS with your peer out of band\n", sas);
+
+        chat(s);
+}
+
+/*
+ * Allocate memory for secrets: excluded from core dumps and,
+ * where the memlock limit allows, from swap.
+ */
+void *
+secalloc(size_t len)
+{
+        void *p = mmap(NULL, len, PROT_READ | PROT_WRITE,
+                MAP_PRIVATE | MAP_ANON | MAP_CONCEAL, -1, 0);
+
+        if (p == MAP_FAILED)
+                err(1, "mmap");
+
+        (void)mlock(p, len); /* best effort, swap is encrypted anyways */
+
+        return p;
+}
+
+static void
+cleanup(void)
+{
+        tty_restore();
+
+        if (pass != NULL)
+                explicit_bzero(pass, PASSMAX);
+
+        proto_wipe();
+}
+
+static void
+sighandler(int sig)
+{
+        cleanup();
+        _exit(128 + sig);
+}
+
+/*
+ * Become nobody for good. From a setuid/setgid nobody binary the
+ * effective and saved ids already are nobody, so an unprivileged
+ * setresuid/setresgid may copy them into the real ids too. Root
+ * can do it outright, plus chroot and setgroups.
+ */
+static void
+droppriv(void)
+{
+        struct passwd *pw;
+        uid_t uid;
+        gid_t gid;
+
+        if ((pw = getpwnam("nobody")) == NULL)
+                errx(1, "no such user: nobody");
+        uid = pw->pw_uid;
+        gid = pw->pw_gid;
+
+        if (geteuid() == 0) {
+                if (chroot("/var/empty") == -1 || chdir("/") == -1)
+                        err(1, "chroot");
+                if (setgroups(1, &gid) == -1)
+                        err(1, "setgroups");
+        } else if (geteuid() != uid) {
+		errx(1, "Not running as nobody: the setuid bit was ignored. "
+			"Install under /usr/local (doas make install) and run it "
+			"from there; /home, /tmp, and /var are mounted nosuid.");
+	}
+
+        if (setresgid(gid, gid, gid) == -1 || setresuid(uid, uid, uid) == -1)
+                errx(1, "cannot become nobody: install ochat setuid and "
+                        "setgid nobody (chown nobody:nobody, chmod 6555)");
+
+        if (getuid() != uid || geteuid() != uid || getgid() != gid || getegid() != gid)
+                errx(1, "failed to become nobody");
+
+        /* We do pledge, which grants no file access at all, later, but still. */
+        if (unveil("/", "") == -1 || unveil(NULL, NULL) == -1)
+                err(1, "unveil");
+}
+
+/* Try once. Refused means the peer is not up yet: return -1. */
+static int
+netconnect(const struct sockaddr_in *peer)
+{
+        int s;
+
+        if ((s = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0)) == -1)
+                err(1, "socket");
+        if (connect(s, (const struct sockaddr *)peer, sizeof(*peer)) == 0)
+                return s;
+        if (errno != ECONNREFUSED)
+                err(1, "connect");
+
+        close(s);
+
+        return -1;
+}
+
+/* Listen on the port, accept only the peer, stop listening. */
+static int
+netaccept(const struct sockaddr_in *peer)
+{
+        struct sockaddr_in sin;
+        socklen_t len;
+        int ls, on = 1, s;
+
+        memset(&sin, 0, sizeof(sin));
+        sin.sin_len = sizeof(sin);
+        sin.sin_family = AF_INET;
+        sin.sin_port = peer->sin_port;
+        sin.sin_addr.s_addr = htonl(INADDR_ANY);
+
+        if ((ls = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0)) == -1)
+                err(1, "socket");
+        if (setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) == -1)
+                err(1, "setsockopt");
+        if (bind(ls, (struct sockaddr *)&sin, sizeof(sin)) == -1)
+                err(1, "bind");
+        if (listen(ls, 1) == -1)
+                err(1, "listen");
+
+        fprintf(stderr, "waiting for peer\n");
+
+        for (;;) {
+                len = sizeof(sin);
+                s = accept4(ls, (struct sockaddr *)&sin, &len, SOCK_CLOEXEC);
+                if (s == -1) {
+                        if (errno == EINTR || errno == ECONNABORTED)
+                                continue;
+                        err(1, "accept");
+                }
+
+                if (sin.sin_addr.s_addr == peer->sin_addr.s_addr)
+                        break;
+
+                close(s); /* not our peer: drop silently */
+        }
+
+        close(ls);
+
+        return s;
+}
+
+/*
+ * Read the passphrase from stdin with echo off.
+ */
+static size_t
+readpass(void)
+{
+        struct termios tio;
+        size_t len = 0;
+        ssize_t n;
+        char c;
+
+        if (isatty(STDIN_FILENO)) {
+                if (tcgetattr(STDIN_FILENO, &tio_saved) == -1)
+                        err(1, "tcgetattr");
+                tio = tio_saved;
+                tio.c_lflag &= ~ECHO;
+                tio_set = 1;
+                if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &tio) == -1)
+                        err(1, "tcsetattr");
+        }
+
+        fputs("passphrase: ", stderr);
+
+        for (;;) {
+                if ((n = read(STDIN_FILENO, &c, 1)) == -1) {
+                        if (errno == EINTR)
+                                continue;
+                        err(1, "read");
+                }
+                if (n == 0 || c == '\n')
+                        break;
+                if (len == PASSMAX)
+                        errx(1, "passphrase too long");
+
+                pass[len++] = c;
+        }
+
+        explicit_bzero(&c, sizeof(c));
+        tty_restore();
+        fputc('\n', stderr);
+
+        return len;
+}
+
+static void
+tty_restore(void)
+{
+        if (tio_set) {
+                (void)tcsetattr(STDIN_FILENO, TCSAFLUSH, &tio_saved);
+                tio_set = 0;
+        }
+}
+
+void
+readfull(int fd, void *buf, size_t len)
+{
+        uint8_t *p = buf;
+        ssize_t n;
+
+        while (len > 0) {
+                if ((n = read(fd, p, len)) == -1) {
+                        if (errno == EINTR)
+                                continue;
+                        err(1, "read");
+                }
+                if (n == 0)
+                        errx(1, "connection closed");
+
+                p += n;
+                len -= n;
+        }
+}
+
+void
+writefull(int fd, const void *buf, size_t len)
+{
+        const uint8_t *p = buf;
+        ssize_t n;
+
+        while (len > 0) {
+                if ((n = write(fd, p, len)) == -1) {
+                        if (errno == EINTR)
+                                continue;
+                        err(1, "write");
+                }
+
+                p += n;
+                len -= n;
+        }
+}
+
+static void
+sendframe(int s, uint8_t type, const uint8_t *msg, size_t len)
+{
+        uint8_t frame[FRAME];
+
+        proto_seal(frame, type, msg, len);
+        writefull(s, frame, sizeof(frame));
+}
+
+/*
+ * Print a peer message with every non-printable byte escaped, so
+ * the peer cannot drive our terminal. Bypasses stdio so no copy
+ * of the plaintext lingers in a FILE buffer.
+ */
+static void
+printmsg(const uint8_t *msg, size_t len)
+{
+        char buf[2 + 4 * MSGMAX + 2];
+        int n;
+
+        buf[0] = '<';
+        buf[1] = ' ';
+        n = strvisx(buf + 2, (const char *)msg, len,
+                VIS_CSTYLE | VIS_OCTAL | VIS_TAB | VIS_NL);
+        buf[2 + n] = '\n';
+
+        writefull(STDOUT_FILENO, buf, 2 + n + 1);
+        explicit_bzero(buf, sizeof(buf));
+}
+
+__dead static void
+chat(int s)
+{
+        struct pollfd pfd[2];
+        uint8_t net[FRAME], in[512], line[MSGMAX], msg[MSGMAX];
+        size_t netlen = 0, linelen = 0, len;
+        ssize_t n;
+
+        pfd[0].fd = STDIN_FILENO;
+        pfd[0].events = POLLIN;
+        pfd[1].fd = s;
+        pfd[1].events = POLLIN;
+
+        for (;;) {
+                if (poll(pfd, 2, INFTIM) == -1) {
+                        if (errno == EINTR)
+                                continue;
+                        err(1, "poll");
+                }
+
+                /* Peer: accumulate exactly FRAME bytes, then decrypt. */
+                if (pfd[1].revents & (POLLIN | POLLHUP | POLLERR)) {
+                        n = read(s, net + netlen, sizeof(net) - netlen);
+                        if (n == -1 && errno != EINTR)
+                                err(1, "read");
+                        if (n == 0)
+                                errx(1, "%s", netlen ?
+                                        "truncated frame" : "peer closed connection");
+                        if (n > 0 && (netlen += n) == FRAME) {
+                                netlen = 0;
+                                if (proto_open(net, msg, &len) == T_BYE) {
+                                        fprintf(stderr, "peer left\n");
+                                        exit(0);
+                                }
+                                printmsg(msg, len);
+                                explicit_bzero(msg, sizeof(msg));
+                        }
+                }
+
+                /* Local: split input into lines of at most MSGMAX bytes. */
+                if (pfd[0].revents & (POLLIN | POLLHUP | POLLERR)) {
+                        n = read(STDIN_FILENO, in, sizeof(in));
+                        if (n == -1 && errno != EINTR)
+                                err(1, "read");
+                        for (int i = 0; i < n; i++) {
+                                if (in[i] != '\n')
+                                        line[linelen++] = in[i];
+                                if ((in[i] == '\n' && linelen > 0) || linelen == MSGMAX) {
+                                        sendframe(s, T_MSG, line, linelen);
+                                        linelen = 0;
+                                }
+                        }
+
+                        explicit_bzero(in, sizeof(in));
+
+                        if (n == 0) {
+                                if (linelen > 0)
+                                        sendframe(s, T_MSG, line, linelen);
+                                sendframe(s, T_BYE, NULL, 0);
+                                explicit_bzero(line, sizeof(line));
+                                exit(0);
+                        }
+                }
+        }
+}
blob - /dev/null
blob + e11fc77b92ec22b5c2417d44483d3ce65644c580 (mode 644)
--- /dev/null
+++ ochat.h
@@ -0,0 +1,24 @@
+/* ochat.h */
+
+#ifndef OCHAT_H
+#define OCHAT_H
+
+#include <stdint.h>
+#include <stddef.h>
+
+#define MSGMAX  254              /* payload bytes per frame */
+#define PTLEN   (2 + MSGMAX)     /* type + len + payload = 256 */
+#define TAGLEN  16               /* poly1305 tag */
+#define FRAME   (PTLEN + TAGLEN) /* bytes on the wire, always */
+#define PASSMAX 256
+#define SASLEN  20               /* XXXX-XXXX-XXXX-XXXX" + NUL */
+
+#define T_MSG   1
+#define T_BYE   2
+
+/* ochat.c */
+void *secalloc(size_t);
+void readfull(int, void *, size_t);
+void writefull(int, const void *, size_t);
+
+#endif /* OCHAT_H */
blob - /dev/null
blob + e26a0b93d4b49272fcadcc45f5f63817b7d1e154 (mode 644)
--- /dev/null
+++ proto.c
@@ -0,0 +1,312 @@
+/* proto.c */
+
+/*
+ * Handshake, framing and key ratchet for ochat.
+ *
+ * Handshake (3 short, fixed-size messages, no lenght fields):
+ *
+ * both: e              ephemeral X25519 public key (32)
+ * both: confirm        AEAD tag over empty plaintext (16)
+ *
+ * dh   = X25519(my_priv, peer_pub)
+ * h    = SHA256("ochat-v1" || init_pub || resp_pub)
+ * psk  = bcrypt(pbkdf(passphrase, salf = h) (zero if no pass)
+ * okm  = HKDF-SHA256(ikm = dh || psk, salt = h, info = "ochat-v1-keys")
+ *        -> k_i2r | k_r2i | k_confirm | sas_raw
+ *
+ * The confirm exchange proves both sides derived the same keys, i.e.
+ * the same DH result and the same passphrase. With no passphrase it
+ * only proves an unbroken DH, so the SAS must be compared out of band.
+ *
+ * Frames: ChaCha20-Poly1305 over the 256-byte plaintext, nonce is a
+ * 64-bit per-direction counter. A gap, replay or reorder fails the
+ * tag and kills the process.
+ *
+ * After each frame its direction's key is advanced by a one-way step,
+ * k' = HKDF(k, "ochat-ratchet"), and the old key is wiped. This gives
+ * forward secrecy within a session: a key seized at frame N cannot
+ * decrypt any earlier frame. (It is not post-compromise secure; that
+ * is the job of the ephemeral per-session keys.)
+ */
+
+#include <sys/types.h>
+
+#include <err.h>
+#include <stdint.h>
+#include <string.h>
+#include <util.h>
+
+#include <openssl/curve25519.h>
+#include <openssl/evp.h>
+#include <openssl/hkdf.h>
+#include <openssl/sha.h>
+
+#include "ochat.h"
+#include "proto.h"
+
+#define KEYLEN          32
+#define PUBLEN          32
+#define PRIVLEN         32
+#define DHLEN           32
+#define HASHLEN         32
+#define NONCELEN        12              /* chacha20-poly1305 nonce */
+#define CONFIRMLEN      TAGLEN          /* AEAD tag over empty plaintext */
+#define SASRAW          8               /* -> 16 hex digits */
+#define OKMLEN          (3 * KEYLEN + SASRAW)
+#define PSKROUNDS       16
+#define KEYSLEN         (2 * KEYLEN)    /* k_send || k_recv */
+
+/*
+ * The only durable key material is the concealed, mlocked page at
+ * st.keys. No EVP_AEAD_CTX is kept between frames: each seal/open
+ * builds one from st.keys and frees it, so libcrypto never holds a
+ * second long-lived copy.
+ */
+static struct {
+        uint8_t *keys;          /* secalloc: k_send(32) || k_recv(32) */
+        uint64_t send_ctr;
+        uint64_t recv_ctr;
+        int ready;
+} st;
+
+static const EVP_AEAD *
+aead(void)
+{
+        return EVP_aead_chacha20_poly1305();
+}
+
+/* Allocate and key a ChaCha20-Poly1305 context. */
+static EVP_AEAD_CTX *
+aead_new(const uint8_t *key)
+{
+        EVP_AEAD_CTX *ctx;
+
+        if ((ctx = EVP_AEAD_CTX_new()) == NULL)
+                errx(1, "AEAD alloc failed");
+        if (!EVP_AEAD_CTX_init(ctx, aead(), key, KEYLEN, TAGLEN, NULL))
+                errx(1, "AEAD init failed");
+
+        return ctx;
+}
+
+/* 96-bit nonce: 32 zero bits then the counter, big-endian */
+static void
+nonce_of(uint8_t n[NONCELEN], uint64_t ctr)
+{
+        memset(n, 0, NONCELEN);
+
+        for (int i = 0; i < 8; i++)
+                n[NONCELEN - 1 - i] = (uint8_t)(ctr >> (8 * i));
+}
+
+/* Advance a direction key in place: k <- HKDF(k, "ochat-ratchet") */
+static void
+ratchet(uint8_t *key)
+{
+        uint8_t next[KEYLEN];
+
+        if (!HKDF(next, KEYLEN, EVP_sha256(), key, KEYLEN, NULL, 0,
+            (const uint8_t *)"ochat-ratchet", 13))
+                errx(1, "ratchet failed");
+
+        memcpy(key, next, KEYLEN);
+        explicit_bzero(next, sizeof(next));
+}
+
+/* sas_raw (8 bytes) -> "XXXX-XXXX-XXXX-XXXX" */
+static void
+format_sas(char *out, size_t outlen, const uint8_t *raw) {
+        static const char hex[] = "0123456789ABCDEF";
+        char tmp[SASLEN];
+        int j = 0;
+
+        for (int i = 0; i < SASRAW; i++) {
+                tmp[j++] = hex[raw[i] >> 4];
+                tmp[j++] = hex[raw[i] & 0x0f];
+
+                if ((i & 1) && i != SASRAW - 1)
+                        tmp[j++] = '-';
+        }
+        tmp[j] = '\0';
+
+        if (strlcpy(out, tmp, outlen) >= outlen)
+                errx(1, "sas buffer too small");
+}
+
+void proto_init(void)
+{
+        memset(&st, 0, sizeof(st));
+        st.keys = secalloc(KEYSLEN);  /* MAP_CONCEAL + mlock; wiped in proto_wipe */
+}
+
+void
+proto_handshake(int fd, int initiator, const uint8_t *pass, size_t passlen,
+        char *sas, size_t saslen)
+{
+        uint8_t my_pub[PUBLEN], my_priv[PRIVLEN], peer_pub[PUBLEN];
+        uint8_t dh[DHLEN], h[HASHLEN], psk[KEYLEN];
+        uint8_t ikm[DHLEN + KEYLEN], okm[OKMLEN];
+        uint8_t tbuf[8 + 2 * PUBLEN];
+        uint8_t my_confirm[CONFIRMLEN], peer_confirm[CONFIRMLEN];
+        uint8_t nonce_i[NONCELEN], nonce_r[NONCELEN], scratch[1];
+        const uint8_t *ksend, *krecv;
+        const char *my_ad, *peer_ad;
+        const uint8_t *my_nonce, *peer_nonce;
+        EVP_AEAD_CTX *cc;
+        size_t outlen;
+
+        if (saslen < SASLEN)
+                errx(1, "sas buffer too small");
+
+        /* Ephemeral keypair, exchange public keys (32 bytes won't block). */
+        X25519_keypair(my_pub, my_priv);
+        writefull(fd, my_pub, PUBLEN);
+        readfull(fd, peer_pub, PUBLEN);
+
+        /* Diffie-Hellman; LibreSSL returns 0 on an all-zero shared key. */
+        if (!X25519(dh, my_priv, peer_pub))
+                errx(1, "X25519: degenerate peer key");
+
+        /* Transcript hash over both public keys in role order. */
+        memcpy(tbuf, "ochat-v1", 8);
+        memcpy(tbuf + 8, initiator ? my_pub : peer_pub, PUBLEN);
+        memcpy(tbuf + 8 + PUBLEN, initiator ? peer_pub : my_pub, PUBLEN);
+        SHA256(tbuf, sizeof(tbuf), h);
+
+        /* Passphrase -> PSK, salted by the transcript hash. */
+        if (passlen > 0) {
+                if (bcrypt_pbkdf((const char *)pass, passlen, h, HASHLEN,
+                    psk, KEYLEN, PSKROUNDS) != 0)
+                        errx(1, "bcrypt_pbkdf failed");
+        } else
+                memset(psk, 0, KEYLEN);
+
+        /* HKDF(dh || psk) -> directional keys, confirm key, SAS */
+        memcpy(ikm, dh, DHLEN);
+        memcpy(ikm + DHLEN, psk, KEYLEN);
+
+        if (!HKDF(okm, OKMLEN, EVP_sha256(), ikm, sizeof(ikm), h, HASHLEN,
+            (const uint8_t *)"ochat-v1-keys", 13))
+                errx(1, "HKDF failed");
+
+        /* Copy the per-direction keys into the concealed page. */
+        ksend = initiator ? okm : okm + KEYLEN;
+        krecv = initiator ? okm + KEYLEN : okm;
+        memcpy(st.keys, ksend, KEYLEN);
+        memcpy(st.keys + KEYLEN, krecv, KEYLEN);
+        st.send_ctr = st.recv_ctr = 0;
+
+        /* Mutual key confirmation: role-tagged AAD, distinct nonces. */
+        cc = aead_new(okm + 2 * KEYLEN);
+        nonce_of(nonce_i, 0);
+        nonce_of(nonce_r, 1);
+        my_ad = initiator ? "ochat-confirm-i" : "ochat-confirm-r";
+        peer_ad = initiator ? "ochat-confirm-r" : "ochat-confirm-i";
+        my_nonce = initiator ? nonce_i : nonce_r;
+        peer_nonce = initiator ? nonce_r : nonce_i;
+
+        if (!EVP_AEAD_CTX_seal(cc, my_confirm, &outlen, CONFIRMLEN,
+            my_nonce, NONCELEN, NULL, 0,
+            (const uint8_t *)my_ad, strlen(my_ad)))
+                errx(1, "confirm seal failed");
+
+        writefull(fd, my_confirm, CONFIRMLEN);
+        readfull(fd, peer_confirm, CONFIRMLEN);
+
+        if (!EVP_AEAD_CTX_open(cc, scratch, &outlen, sizeof(scratch),
+            peer_nonce, NONCELEN, peer_confirm, CONFIRMLEN,
+            (const uint8_t *)peer_ad, strlen(peer_ad)))
+                errx(1, "peer authentication failed");
+
+        EVP_AEAD_CTX_free(cc);
+
+        /* Short authentication string for out-of-band comparison. */
+        format_sas(sas, saslen, okm + 3 * KEYLEN);
+
+        st.ready = 1;
+
+        explicit_bzero(my_priv, sizeof(my_priv));
+        explicit_bzero(dh, sizeof(dh));
+        explicit_bzero(psk, sizeof(psk));
+        explicit_bzero(ikm, sizeof(ikm));
+        explicit_bzero(okm, sizeof(okm));
+}
+
+void
+proto_seal(uint8_t *frame, uint8_t type, const uint8_t *msg, size_t len)
+{
+        EVP_AEAD_CTX *ctx;
+        uint8_t pt[PTLEN], nonce[NONCELEN];
+        size_t outlen;
+
+        if (!st.ready)
+                errx(1, "seal before handshake");
+        if (len > MSGMAX)
+                errx(1, "message to long");
+        if (st.send_ctr == UINT64_MAX)
+                errx(1, "send counter exhausted");
+
+        pt[0] = type;
+        pt[1] = (uint8_t)len;
+        if (len > 0)
+                memcpy(pt + 2, msg, len);
+        memset(pt + 2 + len, 0, MSGMAX - len);
+
+        nonce_of(nonce, st.send_ctr);
+        ctx = aead_new(st.keys);
+
+        if (!EVP_AEAD_CTX_seal(ctx, frame, &outlen, FRAME,
+            nonce, NONCELEN, pt, PTLEN, NULL, 0) || outlen != FRAME)
+                errx(1, "seal failed");
+
+        EVP_AEAD_CTX_free(ctx);
+        st.send_ctr++;
+        ratchet(st.keys);
+        explicit_bzero(pt, sizeof(pt));
+}
+
+uint8_t
+proto_open(const uint8_t *frame, uint8_t *msg, size_t *len)
+{
+        EVP_AEAD_CTX *ctx;
+        uint8_t pt[PTLEN], nonce[NONCELEN], type;
+        size_t outlen;
+
+        if (!st.ready)
+                errx(1, "open before handshake");
+        if (st.recv_ctr == UINT64_MAX)
+                errx(1, "recv counter exhausted");
+
+        nonce_of(nonce, st.recv_ctr);
+        ctx = aead_new(st.keys + KEYLEN);
+
+        if (!EVP_AEAD_CTX_open(ctx, pt, &outlen, PTLEN,
+            nonce, NONCELEN, frame, FRAME, NULL, 0) || outlen != PTLEN)
+                errx(1, "decryption failed"); /* bad tag, replay or reorder */
+
+        EVP_AEAD_CTX_free(ctx);
+        st.recv_ctr++;
+        ratchet(st.keys + KEYLEN);
+
+        type = pt[0];
+        *len = pt[1];
+        if (*len > MSGMAX)
+                errx(1, "invalid message length");
+        if (*len > 0)
+                memcpy(msg, pt + 2, *len);
+
+        explicit_bzero(pt, sizeof(pt));
+
+        return type;
+}
+
+void
+proto_wipe(void)
+{
+        if (st.keys != NULL)
+                explicit_bzero(st.keys, KEYSLEN);
+
+        st.send_ctr = 0;
+        st.recv_ctr = 0;
+        st.ready = 0;
+}
blob - /dev/null
blob + b93dfbe50dd2b6ff9b48dbd5561362ae9692027b (mode 644)
--- /dev/null
+++ proto.h
@@ -0,0 +1,24 @@
+/* proto.h */
+
+#ifndef OCHAT_PROTO_H
+#define OCHAT_PROTO_H
+
+#include <stdint.h>
+#include <stddef.h>
+
+/*
+ * Secure channel: an NNpsk0-style X25519 handshake with optional
+ * passphrase PSK and a short authentication string, then fixed-size
+ * ChaCha20-Poly1305 frames with an implicit per-direction counter.
+ *
+ * Every failure is fatal (errx): a bad tag, a replay, a reordered
+ * frame or a failed peer confirmation all tear the process down.
+ */
+
+void proto_init(void);
+void proto_handshake(int, int, const uint8_t *, size_t, char *, size_t);
+void proto_seal(uint8_t *, uint8_t, const uint8_t *, size_t);
+uint8_t proto_open(const uint8_t *, uint8_t *, size_t *);
+void proto_wipe(void);
+
+#endif /* OCHAT_PROTO_H */