From: David Woodhouse Date: Thu, 7 Apr 2022 11:30:21 +0000 (+0100) Subject: AnyConnect: Add support for external browser SSO X-Git-Tag: v9.00~55 X-Git-Url: https://www.infradead.org/git/?a=commitdiff_plain;h=d738b6f2c3c22294539e0c8486b4935c9c3c654b;p=users%2Fdwmw2%2Fopenconnect.git AnyConnect: Add support for external browser SSO For external browser SSO we need to listen on a local port to accept the encoded token from the browser, as it's passed to us via a redirect to http://localhost:29786/api/sso/ This implements a simple listening loop, accepting connections and decoding the blob we get back. Signed-off-by: David Woodhouse --- diff --git a/Makefile.am b/Makefile.am index eea39a3c..c4c10ab4 100644 --- a/Makefile.am +++ b/Makefile.am @@ -35,7 +35,7 @@ if OPENCONNECT_WIN32 openconnect_SOURCES += openconnect.rc endif library_srcs = ssl.c http.c textbuf.c http-auth.c auth-common.c auth-html.c library.c compat.c lzs.c mainloop.c script.c ntlm.c digest.c mtucalc.c openconnect-internal.h -lib_srcs_cisco = auth.c cstp.c +lib_srcs_cisco = auth.c cstp.c hpke.c lib_srcs_juniper = oncp.c lzo.c auth-juniper.c lib_srcs_pulse = pulse.c lib_srcs_globalprotect = gpst.c win32-ipicmp.h auth-globalprotect.c diff --git a/auth.c b/auth.c index ad07b2a5..72cf7cb4 100644 --- a/auth.c +++ b/auth.c @@ -428,6 +428,7 @@ static int parse_auth_node(struct openconnect_info *vpninfo, xmlNode *xml_node, xmlnode_get_text(xml_node, "sso-v2-login-final", &vpninfo->sso_login_final); xmlnode_get_text(xml_node, "sso-v2-token-cookie-name", &vpninfo->sso_token_cookie); xmlnode_get_text(xml_node, "sso-v2-error-cookie-name", &vpninfo->sso_error_cookie); + xmlnode_get_text(xml_node, "sso-v2-browser-mode", &vpninfo->sso_browser_mode); if (xmlnode_is_named(xml_node, "form")) { @@ -809,6 +810,12 @@ static xmlDocPtr xmlpost_new_query(struct openconnect_info *vpninfo, const char if (!node) goto bad; +#ifdef HAVE_HPKE_SUPPORT + node = xmlNewTextChild(capabilities, NULL, XCAST("auth-method"), XCAST("single-sign-on-external-browser")); + if (!node) + goto bad; +#endif + *rootp = root; return doc; diff --git a/hpke.c b/hpke.c new file mode 100644 index 00000000..aa40057f --- /dev/null +++ b/hpke.c @@ -0,0 +1,318 @@ +/* + * OpenConnect (SSL + DTLS) VPN client + * + * Copyright © 2022 David Woodhouse + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public License + * version 2.1, as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + */ + +#include + +#include "openconnect-internal.h" + +#ifndef HAVE_HPKE_SUPPORT +int handle_external_browser(struct openconnect_info *vpninfo) +{ + return -EINVAL; +} +#else + +#include + +#define HPKE_TAG_PUBKEY 1 +#define HPKE_TAG_AEAD_TAG 2 +#define HPKE_TAG_CIPHERTEXT 3 +#define HPKE_TAG_IV 4 +/* + * Hard-coded HTTP responses + */ +static const char response_404[] = + "HTTP/1.1 404 Not Found\r\n" + "Connection: close\r\n" + "Content-Type: text/html\r\n" + "Content-Length: 0\r\n\r\n"; + +static const char response_302[] = + "HTTP/1.1 302 Found\r\n" + "Connection: close\r\n" + "Content-Type: text/html\r\n" + "Content-Length: 0\r\n" + "Location: %s\r\n\r\n"; + +static const char response_200[] = + "HTTP/1.1 200 OK\r\n" + "Connection: close\r\n" + "Content-Type: text/html\r\n\r\n" + "SuccessSuccess\r\n"; + + +/* + * If we use an external browser where we can't just snoop for cookies + * or completion... how do we get the results back? Cisco's answer: + * We run an HTTP server on http://localhost:29786/ and listen for + * a GET request to /api/sso/?return=. It + * returns a redirect to that final URL, which is a pretty 'success' + * page. And decodes the base64 blob to obtain the SSO token, qv. + */ +int handle_external_browser(struct openconnect_info *vpninfo) +{ + int ret = 0; + struct sockaddr_in6 sin6 = { }; + sin6.sin6_family = AF_INET6; + sin6.sin6_port = htons(29786); + sin6.sin6_addr = in6addr_loopback; + + int listen_fd = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP); + if (listen_fd < 0) { + char *errstr; + sockerr: +#ifdef _WIN32 + errstr = openconnect__win32_strerror(WSAGetLastError()); +#else + errstr = strerror(errno); +#endif + + vpn_progress(vpninfo, PRG_ERR, + _("Failed to listen on local port 29786: %s\n"), + errstr); +#ifdef _WIN32 + free(errstr); +#endif + if (listen_fd >= 0) + closesocket(listen_fd); + return -EIO; + } + + int optval = 1; + setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)); + + if (bind(listen_fd, (void *)&sin6, sizeof(sin6)) < 0) + goto sockerr; + + if (listen(listen_fd, 1)) + goto sockerr; + + if (set_sock_nonblock(listen_fd)) + goto sockerr; + + + /* Now that we are listening on the socket, we can spawn the browser... + or just tell the user to, for now */ + vpn_progress(vpninfo, PRG_ERR, + _("Point browser to this URL:\n%s\n"), + vpninfo->sso_login); + + char *returl = NULL; + struct oc_text_buf *b64_buf = NULL; + + /* There may be other stray connections. Repeat until we have one + * that looks like the actual auth attempt from the browser. */ + while (1) { + int accept_fd = cancellable_accept(vpninfo, listen_fd); + if (accept_fd < 0) { + ret = accept_fd; + goto out; + } + vpn_progress(vpninfo, PRG_TRACE, + _("Accepted incoming external-browser connection on port 29786\n")); + + char line[4096]; + ret = cancellable_gets(vpninfo, accept_fd, line, sizeof(line)); + if (ret < 15 || strncmp(line, "GET /", 5) || + strncmp(line + ret - 9, " HTTP/1.", 8)) { + vpn_progress(vpninfo, PRG_TRACE, + _("Invalid incoming external-browser request\n")); + closesocket(accept_fd); + continue; + } + if (strncmp(line, "GET /api/sso/", 13)) { + give_404: + cancellable_send(vpninfo, accept_fd, response_404, sizeof(response_404) - 1); + closesocket(accept_fd); + continue; + } + + /* + * OK, now we have a "GET /api/sso/… HTTP/1.x" that looks sane. + * Kill the " HTTP/1.x" at the end. + * */ + line[ret - 9] = 0; + + /* Scan for ?return= (and other params that shouldn't be there) */ + char *b64 = line + 13; + char *q = strchr(b64, '?'); + while (q) { + *q = 0; + q++; + if (!strncmp(q, "return=", 7)) + returl = q + 7; + q = strchr(q, '&'); + } + + /* Attempt to decode the base64 */ + urldecode_inplace(b64); + b64_buf = buf_alloc(); + if (!b64_buf) { + ret = -ENOMEM; + closesocket(accept_fd); + goto out; + } + + b64_buf->data = openconnect_base64_decode(&ret, b64); + if (ret < 0) { + /* If the final part of the URL after /api/sso/ is not + * valid base64, give a 404 and wait for a valid req. */ + buf_free(b64_buf); + b64_buf = NULL; + goto give_404; + } + b64_buf->pos = b64_buf->buf_len = ret; + + /* Decode and store the returl (since we'll reuse the line buf) */ + if (returl) { + urldecode_inplace(returl); + returl = strdup(returl); + } + + /* Now consume the rest of the HTTP request lines */ + while (cancellable_gets(vpninfo, accept_fd, line, sizeof(line)) > 0) { + vpn_progress(vpninfo, PRG_DEBUG, + "< %s\n", line); + } + + /* Finally, send the response to redirect to the success page */ + if (returl) { + line[sizeof(line) - 1] = 0; + ret = snprintf(line, sizeof(line) - 1, response_302, returl); + ret = cancellable_send(vpninfo, accept_fd, line, ret); + free(returl); + returl = NULL; + } else { + ret = cancellable_send(vpninfo, accept_fd, response_200, sizeof(response_200) - 1); + } + closesocket(accept_fd); + if (ret < 0) + goto out_b64; + + break; + } + + vpn_progress(vpninfo, PRG_DEBUG, _("Got encrypted SSO token of %d bytes\n"), + b64_buf->pos); + + /* Example encrypted token: + < 0000: 00 01 00 01 00 5b 30 59 30 13 06 07 2a 86 48 ce |.....[0Y0...*.H.| + < 0010: 3d 02 01 06 08 2a 86 48 ce 3d 03 01 07 03 42 00 |=....*.H.=....B.| + < 0020: 04 fa 42 63 40 b6 f4 a6 02 9a dd 57 f5 8c 74 3e |..Bc@......W..t>| + < 0030: 11 82 18 8d 78 c4 b5 13 d0 c7 c0 d7 f9 79 6c 16 |....x........yl.| + < 0040: e9 bc 30 fa f0 ea 09 8d 17 d1 84 e4 08 55 31 28 |..0..........U1(| + < 0050: a6 62 e4 6d c5 7c be 19 d9 14 41 37 20 6e 4c ce |.b.m.|....A7 nL.| + < 0060: 2c 00 02 00 0c ac 81 ce 79 56 6e 4c 00 cc 9b e3 |,.......yVnL....| + < 0070: d0 00 03 00 17 bb 4d 2e 57 61 c0 90 58 86 86 79 |......M.Wa..X..y| + < 0080: 64 05 28 0c c9 f2 c8 c2 2a 2e fb 5c 00 04 00 0c |d.(.....*..\....| + < 0090: 2c 03 5f 13 3c b7 27 7e 36 fe 5a b8 |,._.<.'~6.Z.| + + This contains the server's DH pubkey (type 1) at 0x0008, + the AEAD tag (type 2) at 0x0065, the ciphertext (type 3) at 0x0075 + and the IV (type 4) at 0x0090. + */ + + /* tagdata[0] is unused because I can't be doing with all that + * (HPKE_TAG_IV-1) nonsense. */ + struct { + void *p; + int len; + } tagdata[HPKE_TAG_IV + 1]; + + memset(tagdata, 0, sizeof(tagdata)); + + int pos = 0; + ret = 0; + while (pos < b64_buf->buf_len) { + uint16_t tag, len; + if (pos + 4 > b64_buf->pos) { + ret = -EINVAL; + break; + } + + tag = load_be16(b64_buf->data + pos); + len = load_be16(b64_buf->data + pos + 2); + + /* Special case, first word must be 0x0001 before the TLVs start */ + if (!pos) { + if (tag != 0x0001) { + ret = -EINVAL; + break; + } + pos += 2; + continue; + } + + if (tag < HPKE_TAG_PUBKEY || tag > HPKE_TAG_IV || + tagdata[tag].p || pos + 4 + len > b64_buf->pos) { + ret = -EINVAL; + break; + } + + tagdata[tag].p = b64_buf->data + pos + 4; + tagdata[tag].len = len; + pos += len + 4; + } + + if (!tagdata[HPKE_TAG_PUBKEY].p || !tagdata[HPKE_TAG_CIPHERTEXT].p || + !tagdata[HPKE_TAG_AEAD_TAG].p || tagdata[HPKE_TAG_AEAD_TAG].len != 12 || + !tagdata[HPKE_TAG_IV].p || tagdata[HPKE_TAG_IV].len != 12) + ret = -EINVAL; + + if (ret) { + vpn_progress(vpninfo, PRG_ERR, _("Failed to decode SSO token at %d:\n"), + pos); + dump_buf_hex(vpninfo, PRG_ERR, '<', (void *)b64_buf->data, b64_buf->pos); + goto out_b64; + } + + unsigned char secret[32]; + ret = ecdh_compute_secp256r1(vpninfo, tagdata[HPKE_TAG_PUBKEY].p, + tagdata[HPKE_TAG_PUBKEY].len, secret); + if (ret) + goto out_b64; + + ret = hkdf_sha256_extract_expand(vpninfo, secret, "AC_ECIES", 8); + if (ret) + goto out_b64; + + unsigned char *token = tagdata[HPKE_TAG_CIPHERTEXT].p; + int token_len = tagdata[HPKE_TAG_CIPHERTEXT].len; + ret = aes_256_gcm_decrypt(vpninfo, secret, token, token_len, + tagdata[HPKE_TAG_IV].p, tagdata[HPKE_TAG_AEAD_TAG].p); + if (ret) + goto out_b64; + + int i; + for (i = 0; i < token_len; i++) { + if (!isalnum(token[i])) { + vpn_progress(vpninfo, PRG_ERR, + _("SSO token not alphanumeric\n")); + ret = -EINVAL; + goto out_b64; + } + } + + vpninfo->sso_cookie_value = strndup((char *)token, token_len); + if (!vpninfo->sso_cookie_value) + ret = -ENOMEM; + + out_b64: + buf_free(b64_buf); + out: + closesocket(listen_fd); + return ret; +} +#endif /* HAVE_HPKE_SUPPORT */ diff --git a/library.c b/library.c index cd5fd9ee..969c8b8e 100644 --- a/library.c +++ b/library.c @@ -593,6 +593,15 @@ void openconnect_vpninfo_free(struct openconnect_info *vpninfo) free_strap_keys(vpninfo); free(vpninfo->strap_pubkey); free(vpninfo->strap_dh_pubkey); + + free(vpninfo->sso_username); + free(vpninfo->sso_cookie_value); + free(vpninfo->sso_browser_mode); + free(vpninfo->sso_login); + free(vpninfo->sso_login_final); + free(vpninfo->sso_error_cookie); + free(vpninfo->sso_token_cookie); + free(vpninfo->ppp); buf_free(vpninfo->ppp_tls_connect_req); buf_free(vpninfo->ppp_dtls_connect_req); @@ -1613,28 +1622,40 @@ retry: nuke_opt_values(form->opts); if (do_sso) { - if (vpninfo->open_webview) { - free(vpninfo->sso_cookie_value); - free(vpninfo->sso_username); - vpninfo->sso_cookie_value = NULL; - vpninfo->sso_username = NULL; + free(vpninfo->sso_cookie_value); + free(vpninfo->sso_username); + vpninfo->sso_cookie_value = NULL; + vpninfo->sso_username = NULL; + + /* Handle the special Cisco external browser mode */ + if (vpninfo->sso_browser_mode && !strcmp(vpninfo->sso_browser_mode, "external")) { + ret = handle_external_browser(vpninfo); + } else if (vpninfo->open_webview) { ret = vpninfo->open_webview(vpninfo, vpninfo->sso_login, vpninfo->cbdata); + } else { + vpn_progress(vpninfo, PRG_ERR, + _("No SSO handler\n")); /* XX: print more debugging info */ + ret = -EINVAL; + } + if (!ret) { for (opt = form->opts; opt; opt = opt->next) { if (opt->type == OC_FORM_OPT_SSO_TOKEN) { free(opt->_value); opt->_value = vpninfo->sso_cookie_value; + vpninfo->sso_cookie_value = NULL; } else if (opt->type == OC_FORM_OPT_SSO_USER) { free(opt->_value); opt->_value = vpninfo->sso_username; + vpninfo->sso_username = NULL; } } - vpninfo->sso_username = NULL; - vpninfo->sso_cookie_value = NULL; - } else { - vpn_progress(vpninfo, PRG_ERR, - _("No SSO handler\n")); /* XX: print more debugging info */ - ret = -EINVAL; } + free(vpninfo->sso_username); + vpninfo->sso_username = NULL; + free(vpninfo->sso_cookie_value); + vpninfo->sso_cookie_value = NULL; + free(vpninfo->sso_browser_mode); + vpninfo->sso_browser_mode = NULL; } return ret; diff --git a/openconnect-internal.h b/openconnect-internal.h index 449d2940..629136cc 100644 --- a/openconnect-internal.h +++ b/openconnect-internal.h @@ -773,6 +773,7 @@ struct openconnect_info { char *sso_token_cookie; char *sso_error_cookie; char *sso_cookie_value; + char *sso_browser_mode; int verbose; void *cbdata; @@ -1598,6 +1599,9 @@ int process_auth_form(struct openconnect_info *vpninfo, struct oc_auth_form *for /* This is private for now since we haven't yet worked out what the API will be */ void openconnect_set_juniper(struct openconnect_info *vpninfo); +/* hpke.c */ +int handle_external_browser(struct openconnect_info *vpninfo); + /* version.c */ extern const char openconnect_version_str[]; diff --git a/www/changelog.xml b/www/changelog.xml index 5d078f93..7aa1527a 100644 --- a/www/changelog.xml +++ b/www/changelog.xml @@ -15,6 +15,7 @@
  • OpenConnect HEAD
      +
    • Add support for AnyConnect "external browser" SSO mode (!354).
    • On Windows, fix crash on tunnel setup. (#370, 6a2ffbb)
    • Bugfix RSA SecurID token decryption and PIN entry forms, broken in v8.20. (#388, !344)