gmni

a gemini line mode client
git clone https://git.clttr.info/gmni.git
Log (Feed) | Files | Refs (Tags) | README | LICENSE

commit 02f6af661513683f0c6c1465c5ff1dd8f03a30c9
parent 30660fc160a15504274d40d4a5ec1b31539f8c2f
Author: Drew DeVault <sir@cmpwn.com>
Date:   Mon, 21 Sep 2020 15:37:24 -0400

Implement TOFU

Diffstat:
Mconfigure | 5++++-
Mdoc/gmni.scd | 7++++++-
Mdoc/gmnlm.scd | 7++++++-
Minclude/gmni.h | 5+----
Ainclude/tofu.h | 48++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/client.c | 77++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/gmni.c | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/gmnlm.c | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Asrc/tofu.c | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 480 insertions(+), 37 deletions(-)

diff --git a/configure b/configure @@ -7,7 +7,9 @@ gmni() { src/client.c \ src/escape.c \ src/gmni.c \ - src/url.c + src/tofu.c \ + src/url.c \ + src/util.c } gmnlm() { @@ -16,6 +18,7 @@ gmnlm() { src/escape.c \ src/gmnlm.c \ src/parser.c \ + src/tofu.c \ src/url.c \ src/util.c } diff --git a/doc/gmni.scd b/doc/gmni.scd @@ -6,7 +6,7 @@ gmni - Gemini client # SYNPOSIS -*gmni* [-46lLiIN] [-E _path_] [-d _input_] [-D _path_] _gemini://..._ +*gmni* [-46lLiIN] [-j _mode_] [-E _path_] [-d _input_] [-D _path_] _gemini://..._ # DESCRIPTION @@ -52,6 +52,11 @@ performed with the user's input supplied to the server. *-L* Follow redirects. +*-j* _mode_ + Sets the TOFU (trust on first use) configuration, which controls if the + client shall trust new certificates. _mode_ can be one of *always*, + *once*, or *fail*. + *-i* Print the response status and meta text to stdout. diff --git a/doc/gmnlm.scd b/doc/gmnlm.scd @@ -6,7 +6,7 @@ gmnlm - Gemini line-mode browser # SYNPOSIS -*gmnlm* [-PU] _gemini://..._ +*gmnlm* [-PU] [-j _mode_] _gemini://..._ # DESCRIPTION @@ -14,6 +14,11 @@ gmnlm - Gemini line-mode browser # OPTIONS +*-j* _mode_ + Sets the TOFU (trust on first use) configuration, which controls if the + client shall trust new certificates. _mode_ can be one of *always*, + *once*, or *fail*. + *-P* Disable pagination. diff --git a/include/gmni.h b/include/gmni.h @@ -13,6 +13,7 @@ enum gemini_result { GEMINI_ERR_RESOLVE, GEMINI_ERR_CONNECT, GEMINI_ERR_SSL, + GEMINI_ERR_SSL_VERIFY, GEMINI_ERR_IO, GEMINI_ERR_PROTOCOL, }; @@ -65,10 +66,6 @@ struct gemini_options { // must also be NULL. SSL_CTX *ssl_ctx; - // If NULL, an SSL connection will be established. If set, it is - // presumed that the caller pre-established the SSL connection. - SSL *ssl; - // If ai_family != AF_UNSPEC (the default value on most systems), the // client will connect to this address and skip name resolution. struct addrinfo *addr; diff --git a/include/tofu.h b/include/tofu.h @@ -0,0 +1,48 @@ +#ifndef GEMINI_TOFU_H +#define GEMINI_TOFU_H +#include <limits.h> +#include <openssl/ssl.h> +#include <openssl/x509.h> +#include <time.h> + +enum tofu_error { + TOFU_VALID, + // Expired, wrong CN, etc. + TOFU_INVALID_CERT, + // Cert is valid but we haven't seen it before + TOFU_UNTRUSTED_CERT, + // Cert is valid but we already trust another cert for this host + TOFU_FINGERPRINT_MISMATCH, +}; + +enum tofu_action { + TOFU_ASK, + TOFU_FAIL, + TOFU_TRUST_ONCE, + TOFU_TRUST_ALWAYS, +}; + +struct known_host { + char *host, *fingerprint; + time_t expires; + int lineno; + struct known_host *next; +}; + +// Called when the user needs to be prompted to agree to trust an unknown +// certificate. Return true to trust this certificate. +typedef enum tofu_action (tofu_callback_t)(enum tofu_error error, + const char *fingerprint, struct known_host *host, void *data); + +struct gemini_tofu { + char known_hosts_path[PATH_MAX+1]; + struct known_host *known_hosts; + int lineno; + tofu_callback_t *callback; + void *cb_data; +}; + +void gemini_tofu_init(struct gemini_tofu *tofu, + SSL_CTX *ssl_ctx, tofu_callback_t *cb, void *data); + +#endif diff --git a/src/client.c b/src/client.c @@ -95,6 +95,7 @@ gemini_request(const char *url, struct gemini_options *options, assert(url); assert(resp); resp->meta = NULL; + resp->bio = NULL; if (strlen(url) > 1024) { return GEMINI_ERR_INVALID_URL; } @@ -110,7 +111,7 @@ gemini_request(const char *url, struct gemini_options *options, goto cleanup; } - char *scheme; + char *scheme, *host; if (curl_url_get(uri, CURLUPART_SCHEME, &scheme, 0) != CURLUE_OK) { res = GEMINI_ERR_INVALID_URL; goto cleanup; @@ -120,6 +121,10 @@ gemini_request(const char *url, struct gemini_options *options, goto cleanup; } } + if (curl_url_get(uri, CURLUPART_HOST, &host, 0) != CURLUE_OK) { + res = GEMINI_ERR_INVALID_URL; + goto cleanup; + } if (options && options->ssl_ctx) { resp->ssl_ctx = options->ssl_ctx; @@ -127,42 +132,54 @@ gemini_request(const char *url, struct gemini_options *options, } else { resp->ssl_ctx = SSL_CTX_new(TLS_method()); assert(resp->ssl_ctx); + SSL_CTX_set_verify(resp->ssl_ctx, SSL_VERIFY_PEER, NULL); } + int r; BIO *sbio = BIO_new(BIO_f_ssl()); - if (options && options->ssl) { - resp->ssl = options->ssl; - SSL_up_ref(resp->ssl); - BIO_set_ssl(sbio, resp->ssl, 0); - resp->fd = -1; - } else { - res = gemini_connect(uri, options, resp, &resp->fd); - if (res != GEMINI_OK) { - goto cleanup; - } + res = gemini_connect(uri, options, resp, &resp->fd); + if (res != GEMINI_OK) { + goto cleanup; + } - resp->ssl = SSL_new(resp->ssl_ctx); - assert(resp->ssl); - int r = SSL_set_fd(resp->ssl, resp->fd); - if (r != 1) { - resp->status = r; - res = GEMINI_ERR_SSL; - goto cleanup; - } - r = SSL_connect(resp->ssl); - if (r != 1) { - resp->status = r; - res = GEMINI_ERR_SSL; - goto cleanup; - } - BIO_set_ssl(sbio, resp->ssl, 0); + resp->ssl = SSL_new(resp->ssl_ctx); + assert(resp->ssl); + SSL_set_connect_state(resp->ssl); + if ((r = SSL_set1_host(resp->ssl, host)) != 1) { + goto ssl_error; + } + if ((r = SSL_set_tlsext_host_name(resp->ssl, host)) != 1) { + goto ssl_error; } + if ((r = SSL_set_fd(resp->ssl, resp->fd)) != 1) { + goto ssl_error; + } + if ((r = SSL_connect(resp->ssl)) != 1) { + goto ssl_error; + } + + X509 *cert = SSL_get_peer_certificate(resp->ssl); + if (!cert) { + resp->status = X509_V_ERR_UNSPECIFIED; + res = GEMINI_ERR_SSL_VERIFY; + goto cleanup; + } + X509_free(cert); + + long vr = SSL_get_verify_result(resp->ssl); + if (vr != X509_V_OK) { + resp->status = vr; + res = GEMINI_ERR_SSL_VERIFY; + goto cleanup; + } + + BIO_set_ssl(sbio, resp->ssl, 0); resp->bio = BIO_new(BIO_f_buffer()); BIO_push(resp->bio, sbio); char req[1024 + 3]; - int r = snprintf(req, sizeof(req), "%s\r\n", url); + r = snprintf(req, sizeof(req), "%s\r\n", url); assert(r > 0); r = BIO_puts(sbio, req); @@ -199,6 +216,10 @@ gemini_request(const char *url, struct gemini_options *options, cleanup: curl_url_cleanup(uri); return res; +ssl_error: + res = GEMINI_ERR_SSL; + resp->status = r; + goto cleanup; } void @@ -248,6 +269,8 @@ gemini_strerr(enum gemini_result r, struct gemini_response *resp) return ERR_error_string( SSL_get_error(resp->ssl, resp->status), NULL); + case GEMINI_ERR_SSL_VERIFY: + return X509_verify_cert_error_string(resp->status); case GEMINI_ERR_IO: return "I/O error"; case GEMINI_ERR_PROTOCOL: diff --git a/src/gmni.c b/src/gmni.c @@ -13,6 +13,7 @@ #include <termios.h> #include <unistd.h> #include "gmni.h" +#include "tofu.h" static void usage(const char *argv_0) @@ -57,6 +58,55 @@ get_input(const struct gemini_response *resp, FILE *source) return input; } +struct tofu_config { + struct gemini_tofu tofu; + enum tofu_action action; +}; + +static enum tofu_action +tofu_callback(enum tofu_error error, const char *fingerprint, + struct known_host *host, void *data) +{ + struct tofu_config *cfg = (struct tofu_config *)data; + enum tofu_action action = cfg->action; + switch (error) { + case TOFU_VALID: + assert(0); // Invariant + case TOFU_INVALID_CERT: + fprintf(stderr, + "The server presented an invalid certificate with fingerprint %s.\n", + fingerprint); + if (action == TOFU_TRUST_ALWAYS) { + action = TOFU_TRUST_ONCE; + } + break; + case TOFU_UNTRUSTED_CERT: + fprintf(stderr, + "The certificate offered by this server is of unknown trust. " + "Its fingerprint is: \n" + "%s\n\n", fingerprint); + break; + case TOFU_FINGERPRINT_MISMATCH: + fprintf(stderr, + "The certificate offered by this server DOES NOT MATCH the one we have on file.\n" + "/!\\ Someone may be eavesdropping on or manipulating this connection. /!\\\n" + "The unknown certificate's fingerprint is:\n" + "%s\n\n" + "The expected fingerprint is:\n" + "%s\n\n" + "If you're certain that this is correct, edit %s:%d\n", + fingerprint, host->fingerprint, + cfg->tofu.known_hosts_path, host->lineno); + return TOFU_FAIL; + } + + if (action == TOFU_ASK) { + return TOFU_FAIL; + } + + return action; +} + int main(int argc, char *argv[]) { @@ -71,7 +121,6 @@ main(int argc, char *argv[]) INPUT_READ, INPUT_SUPPRESS, }; - enum input_mode input_mode = INPUT_READ; FILE *input_source = stdin; @@ -82,9 +131,11 @@ main(int argc, char *argv[]) struct gemini_options opts = { .hints = &hints, }; + struct tofu_config cfg; + cfg.action = TOFU_ASK; int c; - while ((c = getopt(argc, argv, "46d:D:E:hlLiINR:")) != -1) { + while ((c = getopt(argc, argv, "46d:D:E:hj:lLiINR:")) != -1) { switch (c) { case '4': hints.ai_family = AF_INET; @@ -115,6 +166,18 @@ main(int argc, char *argv[]) case 'h': usage(argv[0]); return 0; + case 'j': + if (strcmp(optarg, "fail") == 0) { + cfg.action = TOFU_FAIL; + } else if (strcmp(optarg, "once") == 0) { + cfg.action = TOFU_TRUST_ONCE; + } else if (strcmp(optarg, "always") == 0) { + cfg.action = TOFU_TRUST_ALWAYS; + } else { + usage(argv[0]); + return 1; + } + break; case 'l': linefeed = false; break; @@ -153,6 +216,8 @@ main(int argc, char *argv[]) SSL_load_error_strings(); ERR_load_crypto_strings(); + opts.ssl_ctx = SSL_CTX_new(TLS_method()); + gemini_tofu_init(&cfg.tofu, opts.ssl_ctx, &tofu_callback, &cfg); bool exit = false; char *url = strdup(argv[optind]); diff --git a/src/gmnlm.c b/src/gmnlm.c @@ -13,6 +13,7 @@ #include <termios.h> #include <unistd.h> #include "gmni.h" +#include "tofu.h" #include "url.h" #include "util.h" @@ -29,6 +30,8 @@ struct history { struct browser { bool pagination, unicode; struct gemini_options opts; + struct gemini_tofu tofu; + enum tofu_action tofu_mode; FILE *tty; char *plain_url; @@ -657,22 +660,113 @@ do_requests(struct browser *browser, struct gemini_response *resp) return false; } +static enum tofu_action +tofu_callback(enum tofu_error error, const char *fingerprint, + struct known_host *host, void *data) +{ + struct browser *browser = data; + if (browser->tofu_mode != TOFU_ASK) { + return browser->tofu_mode; + } + + static char prompt[8192]; + switch (error) { + case TOFU_VALID: + assert(0); // Invariant + case TOFU_INVALID_CERT: + snprintf(prompt, sizeof(prompt), + "The server presented an invalid certificate. If you choose to proceed, " + "you should not disclose personal information or trust the contents of the page.\n" + "trust [o]nce; [a]bort\n" + "=> "); + break; + case TOFU_UNTRUSTED_CERT: + snprintf(prompt, sizeof(prompt), + "The certificate offered by this server is of unknown trust. " + "Its fingerprint is: \n" + "%s\n\n" + "If you knew the fingerprint to expect in advance, verify that this matches.\n" + "Otherwise, it should be safe to trust this certificate.\n\n" + "[t]rust always; trust [o]nce; [a]bort\n" + "=> ", fingerprint); + break; + case TOFU_FINGERPRINT_MISMATCH: + snprintf(prompt, sizeof(prompt), + "The certificate offered by this server DOES NOT MATCH the one we have on file.\n" + "/!\\ Someone may be eavesdropping on or manipulating this connection. /!\\\n" + "The unknown certificate's fingerprint is:\n" + "%s\n\n" + "The expected fingerprint is:\n" + "%s\n\n" + "If you're certain that this is correct, edit %s:%d\n", + fingerprint, host->fingerprint, + browser->tofu.known_hosts_path, host->lineno); + return TOFU_FAIL; + } + + bool prompting = true; + while (prompting) { + fprintf(browser->tty, "%s", prompt); + + size_t sz = 0; + char *line = NULL; + if (getline(&line, &sz, browser->tty) == -1) { + free(line); + return TOFU_FAIL; + } + if (line[1] != '\n') { + free(line); + continue; + } + + char c = line[0]; + free(line); + + switch (c) { + case 't': + if (error == TOFU_INVALID_CERT) { + break; + } + return TOFU_TRUST_ALWAYS; + case 'o': + return TOFU_TRUST_ONCE; + case 'a': + return TOFU_FAIL; + } + } + + return TOFU_FAIL; +} + int main(int argc, char *argv[]) { struct browser browser = { .pagination = true, + .tofu_mode = TOFU_ASK, .unicode = true, .url = curl_url(), .tty = fopen("/dev/tty", "w+"), }; int c; - while ((c = getopt(argc, argv, "hPU")) != -1) { + while ((c = getopt(argc, argv, "hj:PU")) != -1) { switch (c) { case 'h': usage(argv[0]); return 0; + case 'j': + if (strcmp(optarg, "fail") == 0) { + browser.tofu_mode = TOFU_FAIL; + } else if (strcmp(optarg, "once") == 0) { + browser.tofu_mode = TOFU_TRUST_ONCE; + } else if (strcmp(optarg, "always") == 0) { + browser.tofu_mode = TOFU_TRUST_ALWAYS; + } else { + usage(argv[0]); + return 1; + } + break; case 'P': browser.pagination = false; break; @@ -695,6 +789,8 @@ main(int argc, char *argv[]) SSL_load_error_strings(); ERR_load_crypto_strings(); browser.opts.ssl_ctx = SSL_CTX_new(TLS_method()); + gemini_tofu_init(&browser.tofu, browser.opts.ssl_ctx, + &tofu_callback, &browser); struct gemini_response resp; browser.running = true; diff --git a/src/tofu.c b/src/tofu.c @@ -0,0 +1,201 @@ +#include <assert.h> +#include <errno.h> +#include <libgen.h> +#include <limits.h> +#include <openssl/asn1.h> +#include <openssl/evp.h> +#include <openssl/ssl.h> +#include <openssl/x509.h> +#include <stdio.h> +#include <string.h> +#include <time.h> +#include "tofu.h" +#include "util.h" + +static int +verify_callback(X509_STORE_CTX *ctx, void *data) +{ + // Gemini clients handle TLS verification differently from the rest of + // the internet. We use a TOFU system, so trust is based on two factors: + // + // - Is the certificate valid at the time of the request? + // - Has the user trusted this certificate yet? + // + // If the answer to the latter is "no", then we give the user an + // opportunity to explicitly agree to trust the certificate before + // rejecting it. + // + // If you're reading this code with the intent to re-use it, think + // twice. + // + // TODO: Check that the subject name is valid for the requested URL. + struct gemini_tofu *tofu = (struct gemini_tofu *)data; + X509 *cert = X509_STORE_CTX_get0_cert(ctx); + + int rc; + int day, sec; + const ASN1_TIME *notBefore = X509_get0_notBefore(cert); + const ASN1_TIME *notAfter = X509_get0_notAfter(cert); + if (!ASN1_TIME_diff(&day, &sec, NULL, notBefore)) { + rc = X509_V_ERR_UNSPECIFIED; + goto invalid_cert; + } + if (day > 0 || sec > 0) { + rc = X509_V_ERR_CERT_NOT_YET_VALID; + goto invalid_cert; + } + if (!ASN1_TIME_diff(&day, &sec, NULL, notAfter)) { + rc = X509_V_ERR_UNSPECIFIED; + goto invalid_cert; + } + if (day < 0 || sec < 0) { + rc = X509_V_ERR_CERT_HAS_EXPIRED; + goto invalid_cert; + } + + unsigned char md[256 / 8]; + const EVP_MD *sha512 = EVP_sha512(); + unsigned int len = sizeof(md); + rc = X509_digest(cert, sha512, md, &len); + assert(rc == 1); + + char fingerprint[256 / 8 * 3]; + for (size_t i = 0; i < sizeof(md); ++i) { + snprintf(&fingerprint[i * 3], 4, "%02X%s", + md[i], i + 1 == sizeof(md) ? "" : ":"); + } + + SSL *ssl = X509_STORE_CTX_get_ex_data(ctx, + SSL_get_ex_data_X509_STORE_CTX_idx()); + const char *servername = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name); + if (!servername) { + rc = X509_V_ERR_HOSTNAME_MISMATCH; + goto invalid_cert; + } + + time_t now; + time(&now); + + enum tofu_error error = TOFU_UNTRUSTED_CERT; + struct known_host *host = tofu->known_hosts; + while (host) { + if (host->expires < now) { + goto next; + } + if (strcmp(host->host, servername) != 0) { + goto next; + } + if (strcmp(host->fingerprint, fingerprint) == 0) { + // Valid match in known hosts + return 0; + } + error = TOFU_FINGERPRINT_MISMATCH; + break; +next: + host = host->next; + } + + rc = X509_V_ERR_CERT_UNTRUSTED; + +callback: + switch (tofu->callback(error, fingerprint, host, tofu->cb_data)) { + case TOFU_ASK: + assert(0); // Invariant + case TOFU_FAIL: + X509_STORE_CTX_set_error(ctx, rc); + break; + case TOFU_TRUST_ONCE: + // No further action necessary + return 0; + case TOFU_TRUST_ALWAYS:; + FILE *f = fopen(tofu->known_hosts_path, "a"); + if (!f) { + fprintf(stderr, "Error opening %s for writing: %s\n", + tofu->known_hosts_path, strerror(errno)); + break; + }; + struct tm expires_tm; + ASN1_TIME_to_tm(notAfter, &expires_tm); + time_t expires = mktime(&expires_tm); + fprintf(f, "%s %s %s %ld\n", servername, + "SHA-512", fingerprint, expires); + fclose(f); + + host = calloc(1, sizeof(struct known_host)); + host->host = strdup(servername); + host->fingerprint = strdup(fingerprint); + host->expires = expires; + host->lineno = ++tofu->lineno; + host->next = tofu->known_hosts; + tofu->known_hosts = host; + return 0; + } + + X509_STORE_CTX_set_error(ctx, rc); + return 0; + +invalid_cert: + error = TOFU_INVALID_CERT; + goto callback; +} + + +void +gemini_tofu_init(struct gemini_tofu *tofu, + SSL_CTX *ssl_ctx, tofu_callback_t *cb, void *cb_data) +{ + const struct pathspec paths[] = { + {.var = "GMNIDATA", .path = "/%s"}, + {.var = "XDG_DATA_HOME", .path = "/gmni/%s"}, + {.var = "HOME", .path = "/.local/share/gmni/%s"} + }; + const char *path_fmt = getpath(paths, sizeof(paths) / sizeof(paths[0])); + snprintf(tofu->known_hosts_path, sizeof(tofu->known_hosts_path), + path_fmt, "known_hosts"); + + if (mkdirs(dirname(tofu->known_hosts_path), 0755) != 0) { + snprintf(tofu->known_hosts_path, sizeof(tofu->known_hosts_path), + path_fmt, "known_hosts"); + fprintf(stderr, "Error creating directory %s: %s\n", + dirname(tofu->known_hosts_path), strerror(errno)); + return; + } + + snprintf(tofu->known_hosts_path, sizeof(tofu->known_hosts_path), + path_fmt, "known_hosts"); + + tofu->callback = cb; + tofu->cb_data = cb_data; + SSL_CTX_set_cert_verify_callback(ssl_ctx, verify_callback, tofu); + + FILE *f = fopen(tofu->known_hosts_path, "r"); + if (!f) { + return; + } + size_t n = 0; + char *line = NULL; + while (getline(&line, &n, f) != -1) { + struct known_host *host = calloc(1, sizeof(struct known_host)); + char *tok = strtok(line, " "); + assert(tok); + host->host = strdup(tok); + + tok = strtok(NULL, " "); + assert(tok); + if (strcmp(tok, "SHA-512") != 0) { + free(host); + continue; + } + + tok = strtok(NULL, " "); + assert(tok); + host->fingerprint = strdup(tok); + + tok = strtok(NULL, " "); + assert(tok); + host->expires = strtoul(tok, NULL, 10); + + host->next = tofu->known_hosts; + tofu->known_hosts = host; + } +}