diff --git a/CMakeLists.txt b/CMakeLists.txt index 1eab7c785..34e8f1cdd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,7 +27,7 @@ else() set(CMAKE_C_STANDARD 99) endif() -set(SOURCE_FILES src/utils.c src/pty.c src/protocol.c src/http.c src/server.c) +set(SOURCE_FILES src/totp.c src/utils.c src/pty.c src/protocol.c src/http.c src/server.c) include(FindPackageHandleStandardArgs) @@ -80,6 +80,8 @@ else() endif() endif() +list(APPEND LINK_LIBS m) + add_executable(${PROJECT_NAME} ${SOURCE_FILES}) target_include_directories(${PROJECT_NAME} PUBLIC ${INCLUDE_DIRS}) target_link_libraries(${PROJECT_NAME} ${LINK_LIBS}) diff --git a/README.md b/README.md index 847933230..cc19d80af 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ttyd is a simple command-line tool for sharing terminal over the web. - SSL support based on [OpenSSL](https://www.openssl.org) / [Mbed TLS](https://github.com/Mbed-TLS/mbedtls) - Run any custom command with options - Basic authentication support and many other custom options +- Two-factor authentication (2FA) in which the TOTP code is appended to the password - Cross platform: macOS, Linux, FreeBSD/OpenBSD, [OpenWrt](https://openwrt.org), Windows > ❤ Special thanks to [JetBrains](https://www.jetbrains.com/?from=ttyd) for sponsoring the opensource license to this project. @@ -69,6 +70,7 @@ OPTIONS: -i, --interface Network interface to bind (eg: eth0), or UNIX domain socket path (eg: /var/run/ttyd.sock) -U, --socket-owner User owner of the UNIX domain socket file, when enabled (eg: user:group) -c, --credential Credential for basic authentication (format: username:password) + -1, --totp Time-based one-time password secret (format: [DIGEST:]SECRET[:DIGITS[:INTERVAL[:OFFSET]]]) -H, --auth-header HTTP Header name for auth proxy, this will configure ttyd to let a HTTP reverse proxy handle authentication -u, --uid User id to run with -g, --gid Group id to run with diff --git a/man/ttyd.1 b/man/ttyd.1 index f238524c0..27412f215 100644 --- a/man/ttyd.1 +++ b/man/ttyd.1 @@ -53,6 +53,10 @@ Cross platform: macOS, Linux, FreeBSD/OpenBSD, OpenWrt/LEDE, Windows -c, --credential USER[:PASSWORD] Credential for Basic Authentication (format: username:password) +.PP +-1, --totp [DIGEST:]SECRET[:DIGITS[:INTERVAL[:OFFSET]]] + Time-based one-time password secret (format: [DIGEST:]SECRET[:DIGITS[:INTERVAL[:OFFSET]]]) + .PP -H, --auth-header HTTP Header name for auth proxy, this will configure ttyd to let a HTTP reverse proxy handle authentication diff --git a/man/ttyd.man.md b/man/ttyd.man.md index e98161be3..b6c713e69 100644 --- a/man/ttyd.man.md +++ b/man/ttyd.man.md @@ -32,6 +32,9 @@ ttyd 1 "September 2016" ttyd "User Manual" -c, --credential USER[:PASSWORD] Credential for Basic Authentication (format: username:password) + -1, --totp [DIGEST:]SECRET[:DIGITS[:INTERVAL[:OFFSET]]] + Time-based one-time password secret (format: [DIGEST:]SECRET[:DIGITS[:INTERVAL[:OFFSET]]]) + -H, --auth-header HTTP Header name for auth proxy, this will configure ttyd to let a HTTP reverse proxy handle authentication diff --git a/src/http.c b/src/http.c index eea62afd1..378ecb47b 100644 --- a/src/http.c +++ b/src/http.c @@ -1,10 +1,12 @@ #include #include #include +#include #include "html.h" #include "server.h" #include "utils.h" +#include "totp.h" enum { AUTH_OK, AUTH_FAIL, AUTH_ERROR }; @@ -33,9 +35,23 @@ static int check_auth(struct lws *wsi, struct pss_http *pss) { if(server->credential != NULL) { char buf[256]; + char expect[256]; + char b64expect[256]; + char code[16]; int len = lws_hdr_copy(wsi, buf, sizeof(buf), WSI_TOKEN_HTTP_AUTHORIZATION); if (len >= 7 && strstr(buf, "Basic ")) { - if (!strcmp(buf + 6, server->credential)) return AUTH_OK; + if(server->totp != NULL && TOTP_GenerateFromSpec(server->totp, time(NULL), code, sizeof(code)) == 0) { + if (server->last_totp_code[0] == '\0' || strcmp(code, server->last_totp_code) != 0) { + snprintf(expect, sizeof(expect), "%s%s", server->credential_dec, code); + lws_b64_encode_string(expect, strlen(expect), b64expect, sizeof(b64expect)); + if (!strcmp(buf + 6, b64expect)) { + strncpy(server->last_totp_code, code, sizeof(server->last_totp_code)); + return AUTH_OK; + } + } + } else { + if (!strcmp(buf + 6, server->credential)) return AUTH_OK; + } } return send_unauthorized(wsi, HTTP_STATUS_UNAUTHORIZED, WSI_TOKEN_HTTP_WWW_AUTHENTICATE); } @@ -102,11 +118,14 @@ int callback_http(struct lws *wsi, enum lws_callback_reasons reason, void *user, snprintf(pss->path, sizeof(pss->path), "%s", (const char *)in); switch (check_auth(wsi, pss)) { case AUTH_OK: + lwsl_notice("AUTH_OK!!!\n"); break; case AUTH_FAIL: + lwsl_notice("AUTH_FAIL!!!\n"); return 0; case AUTH_ERROR: default: + lwsl_notice("AUTH_ERROR!!!\n"); return 1; } diff --git a/src/protocol.c b/src/protocol.c index 53e65d4dd..c0cedd0fa 100644 --- a/src/protocol.c +++ b/src/protocol.c @@ -5,10 +5,12 @@ #include #include #include +#include #include "pty.h" #include "server.h" #include "utils.h" +#include "totp.h" // initial message list static char initial_cmds[] = {SET_WINDOW_TITLE, SET_PREFERENCES}; @@ -187,8 +189,24 @@ static bool check_auth(struct lws *wsi, struct pss_tty *pss) { if (server->credential != NULL) { char buf[256]; + char expect[256]; + char b64expect[256]; + char code[16]; size_t n = lws_hdr_copy(wsi, buf, sizeof(buf), WSI_TOKEN_HTTP_AUTHORIZATION); - return n >= 7 && strstr(buf, "Basic ") && !strcmp(buf + 6, server->credential); + if (n >= 7 && strstr(buf, "Basic ")) { + if(server->totp != NULL && TOTP_GenerateFromSpec(server->totp, time(NULL), code, sizeof(code)) == 0) { + snprintf(expect, sizeof(expect), "%s%s", server->credential_dec, server->last_totp_code); + lws_b64_encode_string(expect, strlen(expect), b64expect, sizeof(b64expect)); + if (!strcmp(buf + 6, b64expect)) return true; + } else { + if (!strcmp(buf + 6, server->credential)) return true; + } + } else { + if(server->totp != NULL && TOTP_GenerateFromSpec(server->totp, time(NULL), code, sizeof(code)) == 0) { + if (!strcmp(code, server->last_totp_code)) return true; + } + } + return false; } return true; @@ -209,7 +227,12 @@ int callback_tty(struct lws *wsi, enum lws_callback_reasons reason, void *user, lwsl_warn("refuse to serve WS client due to the --max-clients option.\n"); return 1; } - if (!check_auth(wsi, pss)) return 1; + if (!check_auth(wsi, pss)) { + lwsl_notice("AUTH_ERROR!!! (protocol.c)\n"); + return 1; + } else { + lwsl_notice("AUTH_OK!!! (protocol.c)\n"); + } n = lws_hdr_copy(wsi, pss->path, sizeof(pss->path), WSI_TOKEN_GET_URI); #if defined(LWS_ROLE_H2) diff --git a/src/server.c b/src/server.c index def8bc892..8033d0cbb 100644 --- a/src/server.c +++ b/src/server.c @@ -55,6 +55,7 @@ static const struct option options[] = {{"port", required_argument, NULL, 'p'}, {"interface", required_argument, NULL, 'i'}, {"socket-owner", required_argument, NULL, 'U'}, {"credential", required_argument, NULL, 'c'}, + {"totp", required_argument, NULL, '1'}, {"auth-header", required_argument, NULL, 'H'}, {"uid", required_argument, NULL, 'u'}, {"gid", required_argument, NULL, 'g'}, @@ -84,7 +85,7 @@ static const struct option options[] = {{"port", required_argument, NULL, 'p'}, {"version", no_argument, NULL, 'v'}, {"help", no_argument, NULL, 'h'}, {NULL, 0, 0, 0}}; -static const char *opt_string = "p:i:U:c:H:u:g:s:w:I:b:P:f:6aSC:K:A:Wt:T:Om:oqBd:vh"; +static const char *opt_string = "p:i:U:c:1:H:u:g:s:w:I:b:P:f:6aSC:K:A:Wt:T:Om:oqBd:vh"; static void print_help() { // clang-format off @@ -98,6 +99,7 @@ static void print_help() { " -i, --interface Network interface to bind (eg: eth0), or UNIX domain socket path (eg: /var/run/ttyd.sock)\n" " -U, --socket-owner User owner of the UNIX domain socket file, when enabled (eg: user:group)\n" " -c, --credential Credential for basic authentication (format: username:password)\n" + " -1, --totp Time-based one-time password secret (format: [DIGEST:]SECRET[:DIGITS[:INTERVAL[:OFFSET]]])\n" " -H, --auth-header HTTP Header name for auth proxy, this will configure ttyd to let a HTTP reverse proxy handle authentication\n" " -u, --uid User id to run with\n" " -g, --gid Group id to run with\n" @@ -139,6 +141,7 @@ static void print_help() { static void print_config() { lwsl_notice("tty configuration:\n"); if (server->credential != NULL) lwsl_notice(" credential: %s\n", server->credential); + if (server->totp != NULL) lwsl_notice(" totp secret: %s\n", server->totp); lwsl_notice(" start command: %s\n", server->command); lwsl_notice(" close signal: %s (%d)\n", server->sig_name, server->sig_code); lwsl_notice(" terminal type: %s\n", server->terminal_type); @@ -206,6 +209,8 @@ static struct server *server_new(int argc, char **argv, int start) { static void server_free(struct server *ts) { if (ts == NULL) return; if (ts->credential != NULL) free(ts->credential); + if (ts->credential_dec != NULL) free(ts->credential_dec); + if (ts->totp != NULL) free(ts->totp); if (ts->auth_header != NULL) free(ts->auth_header); if (ts->index != NULL) free(ts->index); if (ts->cwd != NULL) free(ts->cwd); @@ -344,6 +349,8 @@ int main(int argc, char **argv) { json_object_object_add(client_prefs, "isWindows", json_object_new_boolean(true)); #endif + server->last_totp_code[0] = '\0'; + // parse command line options int c; while ((c = getopt_long(start, argv, opt_string, options, NULL)) != -1) { @@ -401,6 +408,10 @@ int main(int argc, char **argv) { char b64_text[256]; lws_b64_encode_string(optarg, strlen(optarg), b64_text, sizeof(b64_text)); server->credential = strdup(b64_text); + server->credential_dec = strdup(optarg); + break; + case '1': + server->totp = strdup(optarg); break; case 'H': server->auth_header = strdup(optarg); diff --git a/src/server.h b/src/server.h index e13d63271..8bbcae255 100644 --- a/src/server.h +++ b/src/server.h @@ -65,6 +65,9 @@ struct server { int client_count; // client count char *prefs_json; // client preferences char *credential; // encoded basic auth credential + char *credential_dec; // decoded basic auth credential + char *totp; // time-based one-time password secret + char last_totp_code[16]; // last-used TOTP code, max 8 digits + NUL char *auth_header; // header name used for auth proxy char *index; // custom index.html char *command; // full command line diff --git a/src/totp.c b/src/totp.c new file mode 100644 index 000000000..422d8b4d9 --- /dev/null +++ b/src/totp.c @@ -0,0 +1,167 @@ +#include "totp.h" +#include +#include +#include +#include /* for strcasecmp */ +#include +#include +#include +#include + +static const char *BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/* + * Decode Base32 (no padding) into a dynamically allocated buffer. + * *out_len will be set to the byte‐length of the result. + * Returns 0 on success, -1 on failure. + */ +static int base32_decode(const char *in, uint8_t **out, size_t *out_len) { + size_t i, bitbuf = 0, bits = 0; + size_t in_len = strlen(in); + uint8_t *buf = malloc(in_len * 5 / 8 + 1); + if (!buf) return -1; + *out_len = 0; + + for (i = 0; i < in_len; i++) { + char c = toupper((unsigned char)in[i]); + const char *p = strchr(BASE32_CHARS, c); + if (!p) break; /* stop at first invalid char */ + + bitbuf = (bitbuf << 5) | (p - BASE32_CHARS); + bits += 5; + if (bits >= 8) { + bits -= 8; + buf[*out_len] = (uint8_t)(bitbuf >> bits); + (*out_len)++; + bitbuf &= ((1 << bits) - 1); + } + } + *out = buf; + return 0; +} + +int TOTP_Generate(const char *base32_secret, + const EVP_MD *md, + uint64_t t, + int digits, + uint64_t interval, + int64_t t0, + char *out, + size_t out_len) +{ + if (!base32_secret || !md || !out || out_len < (size_t)digits + 1) + return -1; + + /* 1) decode the shared secret */ + uint8_t *key = NULL; + size_t key_len = 0; + if (base32_decode(base32_secret, &key, &key_len) < 0) + return -1; + + /* 2) compute time‐step counter */ + uint64_t steps = (t - t0) / interval; + + /* 3) 8‐byte big‐endian counter */ + uint8_t msg[8]; + for (int i = 0; i < 8; i++) + msg[7 - i] = (uint8_t)(steps >> (i * 8)); + + /* 4) HMAC(hash, key, msg) */ + unsigned char hmac[EVP_MAX_MD_SIZE]; + unsigned int hmac_len = 0; + if (!HMAC(md, key, (int)key_len, msg, sizeof(msg), hmac, &hmac_len)) { + free(key); + return -1; + } + free(key); + + /* 5) dynamic truncation */ + int offset = hmac[hmac_len - 1] & 0x0F; + uint32_t bin = + ((hmac[offset + 0] & 0x7F) << 24) | + ((hmac[offset + 1] & 0xFF) << 16) | + ((hmac[offset + 2] & 0xFF) << 8) | + ((hmac[offset + 3] & 0xFF) << 0); + + /* 6) compute OTP value */ + uint32_t otp = bin % (uint32_t)pow(10, digits); + + /* 7) format zero-padded result into out[] */ + char fmt[8]; + snprintf(fmt, sizeof(fmt), "%%0%dd", digits); + snprintf(out, out_len, fmt, otp); + return 0; +} + +int TOTP_GenerateFromSpec(const char *spec, + time_t t, + char *out, + size_t out_len) +{ + if (!spec || !out) return -1; + + /* make a writable copy */ + char *buf = strdup(spec); + if (!buf) return -1; + + const EVP_MD *md = EVP_sha1(); + char *secret = NULL; + + /* tokenize on ':' */ + char *saveptr = NULL; + char *token = strtok_r(buf, ":", &saveptr); + if (!token) { + free(buf); + return -1; + } + + /* first token may be the digest name */ + if (strcasecmp(token, "sha1") == 0) { + md = EVP_sha1(); + token = strtok_r(NULL, ":", &saveptr); + } + else if (strcasecmp(token, "sha256") == 0) { + md = EVP_sha256(); + token = strtok_r(NULL, ":", &saveptr); + } + else if (strcasecmp(token, "sha512") == 0) { + md = EVP_sha512(); + token = strtok_r(NULL, ":", &saveptr); + } + else { + /* no digest prefix, first token is the secret */ + } + + if (!token) { /* must have at least SECRET */ + free(buf); + return -1; + } + secret = token; + + /* optional fields */ + int digits = 6; + uint64_t interval = 30; + int64_t offset = 0; + + token = strtok_r(NULL, ":", &saveptr); + if (token) digits = atoi(token); + + token = strtok_r(NULL, ":", &saveptr); + if (token) interval = strtoull(token, NULL, 10); + + token = strtok_r(NULL, ":", &saveptr); + if (token) offset = strtoll(token, NULL, 10); + + /* call the core generator */ + int rc = TOTP_Generate(secret, + md, + t, + digits, + interval, + offset, + out, + out_len); + + free(buf); + return rc; +} diff --git a/src/totp.h b/src/totp.h new file mode 100644 index 000000000..f711d4553 --- /dev/null +++ b/src/totp.h @@ -0,0 +1,49 @@ +#ifndef TOTP_H +#define TOTP_H + +#include +#include +#include +#include + +/* + * TOTP_Generate() + * + * base32_secret: ASCII Base32-encoded shared secret (no padding). + * md: hash algorithm, e.g. EVP_sha1(), EVP_sha256(), EVP_sha512(). + * t: current Unix time (seconds), e.g. time(NULL). + * digits: number of digits in OTP (usually 6,7 or 8). + * interval: time step in seconds (usually 30). + * t0: epoch offset (usually 0). + * out: buffer to receive \0-terminated OTP string. + * out_len: size of out[]; must be ≥ digits+1. + * + * Returns 0 on success (out contains the zero-padded OTP), or –1 on error. + */ +int TOTP_Generate(const char *base32_secret, + const EVP_MD *md, + uint64_t t, + int digits, + uint64_t interval, + int64_t t0, + char *out, + size_t out_len); + + +/* + * Parse a specifier of the form + * [DIGEST:]SECRET[:DIGITS[:INTERVAL[:OFFSET]]] + * and generate the OTP for time t. + * + * spec: the specification string, see above + * t: current Unix time (e.g. time(NULL)) + * out: must be at least (digits+1) bytes + * out_len: length of out[] + * + * Returns 0 on success, -1 on error. + */ +int TOTP_GenerateFromSpec(const char *spec, + time_t t, + char *out, + size_t out_len); +#endif /* TOTP_H */