diff --git a/READMEs/README.jwt.md b/READMEs/README.jwt.md new file mode 100644 index 000000000..e0c7e25fe --- /dev/null +++ b/READMEs/README.jwt.md @@ -0,0 +1,176 @@ +# JWT support in lws + +lws supports the common usage scenarios of JWS (signed) JWT generation, +parsing and transferring in and out as http cookies. Care is taken to provide +helpers that implement the current security best practices for cookie handling +and JWT validation. All of the common algorithms like ES512 are supported +along with JWK generation and handling apis. + +The build options needed are `-DLWS_WITH_JOSE=1` `-DLWS_WITH_GENCRYPTO=1`. + +Underlying JOSE primitives are exposed as apis, some JWT specific primitives +and finally a JWT-via http cookie level creation apis each building on top of +what was underneath. + +The higher level APIs are provided additionally because they have the most +opportunity for implementation pitfalls like not validating alg carefully, or +not using the latest cookie security options; the provided APIs handle that +centrally for you. If your needs vary from what the higher level apis are +doing, you can cut-and-paste out those implementations and create your own +using the public lower level apis. + +## LWS JWT fields + +Lws JWT uses mainly well-known fields + +Field|Std|Meaning +---|---|--- +iss|yes|Issuer, typically the domain like "warmcat.com" +aud|yes|Audience, typically a url path like "https://warmcat.com/sai" +iat|yes|Unix-time "Issued At" +nbf|yes|Unix-time "Not Before" +exp|yes|Unix-time "Expired" +sub|yes|Subject, eg, a username or user email +csrf|no|A random 16-char hex token generated with the JWT for use in links specific to the JWT bearer +ext|no|Application-specific JSON sub-object with whatever fields you need, eg, `"authorization": 1` + +## Approach for JWT as session token + +Once JWTs are produced, they are autonomous bearer tokens, if they are not kept +secret between the browser and the site, they will be accepted as evidence for +having rights to the session from anyone. + +Requiring https, and various other cookie hardening techniques make it more +difficult for them to leak, but it is still necessary to strictly constrain the +token's validity time, usually to a few tens of minutes or how long it takes a +user to login and get stuff done on the site in one session. + +## CSRF mitigation + +Cross Site Request Forgery (CSRF) is a hacking scenario where an authorized +user with a valid token is tricked into clicking on an external link that +performs some action with side-effects on the site he has active auth on. For +example, he has a cookie that's logged into his bank, and the link posts a form +to the bank site transferring money to the attacker. + +Lws JWT mitigates this possibility by putting a random secret in the generated +JWT; when the authorized user presents his JWT to generate the page, generated +links that require auth to perform their actions include the CSRF string from +that user's current JWT. + +When the user clicks those links intentionally, the CSRF string in the link +matches the CSRF string in the currently valid JWT that was also provided to +the server along with the click, and all is well. + +An attacker does not know the random, ephemeral JWT CSRF secret to include in +forged links, so the attacker-controlled action gets rejected at the server as +having used an invalid link. + +The checking and link manipulation need to be implemented in user code / JS... +lws JWT provides the random CSRF secret in the JWT and makes it visible to the +server when the incoming JWT is processed. + +## Need for client tracking of short JWT validity times + +Many links or references on pages do not require CSRF strings, only those that +perform actions with side-effects like deletion or money transfer should need +protecting this way. + +Due to CSRF mitigation, generated pages containing the protected links +effectively have an expiry time linked to that of the JWT, since only the bearer +of the JWT used to generate the links on the page can use them; once that +expires actually nobody can use them and the page contents, which may anyway +be showing content that only authenticated users can see must be invalidated and +re-fetched. Even if the contents are visible without authentication, additional +UI elements like delete buttons that should only be shown when authenticated +will wrongly still be shown + +For that reason, the client should be informed by the server along with the +authentication status, the expiry time of it. The client should then by itself +make arrangements to refresh the page when this time is passed, +either showing an unauthenticated version of the same page if it exists, or by +redirecting to the site homepage if showing any of the contents required +authentication. The user can then log back in using his credientials typically +stored in the browser's password store and receive a new short-term JWT with a +new random csrf token along with a new page using the new csrf token in its +links. + +## Considerations for long-lived connections + +Once established as authorized, websocket links may be very long-lived and hold +their authorization state at the server. Although the browser monitoring the +JWT reloading the page on auth expiry should mitigate this, an attacker can +choose to just not do that and have an immortally useful websocket link. + +At least for actions on the long-lived connection, it should not only confirm +the JWT authorized it but that the current time is still before the "exp" time +in the JWT, this is made available as `expiry_unix_time` in the args struct +after successful validation. + +Ideally the server should close long-lived connections according to their auth +expiry time. + +## JWT lower level APIs + +The related apis are in `./include/libwebsockets/lws-jws.h` + +### Validation of JWT + +``` +int +lws_jwt_signed_validate(struct lws_context *ctx, struct lws_jwk *jwk, + const char *alg_list, const char *com, size_t len, + char *temp, int tl, char *out, size_t *out_len); +``` + +### Composing and signing JWT + +``` +int +lws_jwt_sign_compact(struct lws_context *ctx, struct lws_jwk *jwk, + const char *alg, char *out, size_t *out_len, char *temp, + int tl, const char *format, ...); +``` + +## JWT creation and cookie get / set API + +Both the validation and signing apis use the same struct to contain their +aguments. + +``` +struct lws_jwt_sign_set_cookie { + struct lws_jwk *jwk; + /**< entry: required signing key */ + const char *alg; + /**< entry: required signing alg, eg, "ES512" */ + const char *iss; + /**< entry: issuer name to use */ + const char *aud; + /**< entry: audience */ + const char *cookie_name; + /**< entry: the name of the cookie */ + char sub[33]; + /**< sign-entry, validate-exit: subject */ + const char *extra_json; + /**< sign-entry, validate-exit: + * optional "ext" JSON object contents for the JWT */ + size_t extra_json_len; + /**< validate-exit: + * length of optional "ext" JSON object contents for the JWT */ + const char *csrf_in; + /**< validate-entry: + * NULL, or an external CSRF token to check against what is in the JWT */ + unsigned long expiry_unix_time; + /**< sign-entry: seconds the JWT and cookie may live, + * validate-exit: expiry unix time */ +}; + +int +lws_jwt_sign_token_set_http_cookie(struct lws *wsi, + const struct lws_jwt_sign_set_cookie *i, + uint8_t **p, uint8_t *end); +int +lws_jwt_get_http_cookie_validate_jwt(struct lws *wsi, + struct lws_jwt_sign_set_cookie *i, + char *out, size_t *out_len); +``` diff --git a/include/libwebsockets/lws-jws.h b/include/libwebsockets/lws-jws.h index 13fd26a36..c59affb96 100644 --- a/include/libwebsockets/lws-jws.h +++ b/include/libwebsockets/lws-jws.h @@ -454,4 +454,106 @@ LWS_VISIBLE LWS_EXTERN int lws_jwt_sign_compact(struct lws_context *ctx, struct lws_jwk *jwk, const char *alg, char *out, size_t *out_len, char *temp, int tl, const char *format, ...) LWS_FORMAT(8); + +/** + * lws_jwt_token_sanity() - check a validated jwt payload for sanity + * + * \param in: the JWT payload + * \param in_len: the length of the JWT payload + * \param iss: the expected issuer of the token + * \param aud: the expected audience of the token + * \param csrf_in: NULL, or the csrf token that came in on a URL + * \param sub: a buffer to hold the subject name in the JWT (eg, account name) + * \param sub_len: the max length of the sub buffer + * \param secs_left: set to the number of seconds of valid auth left if valid + * + * This performs some generic sanity tests on validated JWT payload... + * + * - the issuer is as expected + * - the audience is us + * - current time is OK for nbf ("not before") in the token + * - current time is OK for exp ("expiry") in the token + * - if csrf_in is not NULL, that the JWK has a csrf and it matches it + * - if sub is not NULL, that the JWK provides a subject (and copies it to sub) + * + * If the tests pass, *secs_left is set to the number of remaining seconds the + * auth is valid. + * + * Returns 0 if no inconsistency, else nonzero. + */ +LWS_VISIBLE LWS_EXTERN int +lws_jwt_token_sanity(const char *in, size_t in_len, + const char *iss, const char *aud, const char *csrf_in, + char *sub, size_t sub_len, unsigned long *exp_unix_time); + +#if defined(LWS_ROLE_H1) || defined(LWS_ROLE_H2) + +struct lws_jwt_sign_set_cookie { + struct lws_jwk *jwk; + /**< entry: required signing key */ + const char *alg; + /**< entry: required signing alg, eg, "ES512" */ + const char *iss; + /**< entry: issuer name to use */ + const char *aud; + /**< entry: audience */ + const char *cookie_name; + /**< entry: the name of the cookie */ + char sub[33]; + /**< sign-entry, validate-exit: subject */ + const char *extra_json; + /**< sign-entry, validate-exit: + * optional "ext" JSON object contents for the JWT */ + size_t extra_json_len; + /**< validate-exit: + * length of optional "ext" JSON object contents for the JWT */ + const char *csrf_in; + /**< validate-entry: + * NULL, or an external CSRF token to check against what is in the JWT */ + unsigned long expiry_unix_time; + /**< sign-entry: seconds the JWT and cookie may live, + * validate-exit: expiry unix time */ +}; + +/** + * lws_jwt_sign_token_set_cookie() - creates sets a JWT in a wsi cookie + * + * \param wsi: the wsi to create the cookie header on + * \param i: structure describing what should be in the JWT + * \param p: wsi headers area + * \param end: end of wsi headers area + * + * Creates a JWT specified \p i, and attaches it to the outgoing headers on + * wsi. Returns 0 if successful. + * + * Best-practice security restrictions are applied to the cookie set action, + * including forcing httponly, and __Host- prefix. As required by __Host-, the + * cookie Path is set to /. __Host- is applied by the function, the cookie_name + * should just be "xyz" for "__Host-xyz". + * + * \p extra_json should just be the bare JSON, a { } is provided around it by + * the function if it's non-NULL. For example, "\"authorization\": 1". + * + * It's recommended the secs parameter is kept as small as consistent with one + * user session on the site if possible, eg, 10 minutes or 20 minutes. At the + * server, it can determine how much time is left in the auth and inform the + * client; if the JWT validity expires, the page should reload so the UI always + * reflects what's possible to do with the authorization state correctly. If + * the JWT expires, the user can log back in using credentials usually stored in + * the browser and auto-filled-in, so this is not very inconvenient. + * + * This is a helper on top of the other JOSE and JWT apis that somewhat crosses + * over between JWT and HTTP, since it knows about cookies. So it is only built + * if both LWS_WITH_JOSE and one of the http-related roles enabled. + */ +LWS_VISIBLE LWS_EXTERN int +lws_jwt_sign_token_set_http_cookie(struct lws *wsi, + const struct lws_jwt_sign_set_cookie *i, + uint8_t **p, uint8_t *end); +LWS_VISIBLE LWS_EXTERN int +lws_jwt_get_http_cookie_validate_jwt(struct lws *wsi, + struct lws_jwt_sign_set_cookie *i, + char *out, size_t *out_len); +#endif + ///@} diff --git a/include/libwebsockets/lws-misc.h b/include/libwebsockets/lws-misc.h index e9185504f..2a33eba53 100644 --- a/include/libwebsockets/lws-misc.h +++ b/include/libwebsockets/lws-misc.h @@ -261,6 +261,26 @@ lws_json_simple_strcmp(const char *buf, size_t len, const char *name, const char LWS_VISIBLE LWS_EXTERN int lws_hex_to_byte_array(const char *h, uint8_t *dest, int max); + +/** + * lws_hex_random(): generate len - 1 or - 2 characters of random ascii hex + * + * \param context: the lws_context used to get the random + * \param dest: destination for hex ascii chars + * \param len: the number of bytes the buffer dest points to can hold + * + * This creates random ascii-hex strings up to a given length, with a + * terminating NUL. Hex characters are produced in pairs, if the length of + * the destination buffer is even, after accounting for the NUL there will be + * an unused byte at the end after the NUL. So lengths should be odd to get + * length - 1 characters exactly followed by the NUL. + * + * There will not be any characters produced that are not 0-9, a-f, so it's + * safe to go straight into, eg, JSON. + */ +LWS_VISIBLE LWS_EXTERN int +lws_hex_random(struct lws_context *context, char *dest, size_t len); + /* * lws_timingsafe_bcmp(): constant time memcmp * diff --git a/lib/core/libwebsockets.c b/lib/core/libwebsockets.c index aae71555c..bcd3aceec 100644 --- a/lib/core/libwebsockets.c +++ b/lib/core/libwebsockets.c @@ -153,6 +153,27 @@ lws_hex_to_byte_array(const char *h, uint8_t *dest, int max) return lws_ptr_diff(dest, odest); } +static char *hexch = "0123456789abcdef"; + +int +lws_hex_random(struct lws_context *context, char *dest, size_t len) +{ + size_t n = (len - 1) / 2; + uint8_t b, *r = (uint8_t *)dest + len - n; + + if (lws_get_random(context, r, n) != n) + return 1; + + while (n--) { + b = *r++; + *dest++ = hexch[b >> 4]; + *dest++ = hexch[b & 0xf]; + } + + *dest = '\0'; + + return 0; +} #if !defined(LWS_PLAT_OPTEE) diff --git a/lib/jose/jws/jws.c b/lib/jose/jws/jws.c index 27ca873af..e2c6f2a83 100644 --- a/lib/jose/jws/jws.c +++ b/lib/jose/jws/jws.c @@ -957,9 +957,9 @@ lws_jwt_signed_validate(struct lws_context *ctx, struct lws_jwk *jwk, { struct lws_tokenize ts; struct lws_jose jose; + int otl = tl, r = 1; struct lws_jws jws; - size_t n, r = 1; - int otl = tl; + size_t n; memset(&jws, 0, sizeof(jws)); lws_jose_init(&jose); @@ -969,7 +969,8 @@ lws_jwt_signed_validate(struct lws_context *ctx, struct lws_jwk *jwk, * blocks */ - n = lws_jws_compact_decode(com, len, &jws.map, &jws.map_b64, temp, &tl); + n = lws_jws_compact_decode(com, (int)len, &jws.map, &jws.map_b64, + temp, &tl); if (n != 3) { lwsl_err("%s: concat_map failed: %d\n", __func__, (int)n); goto bail; @@ -1146,3 +1147,90 @@ bail: return r; } + +int +lws_jwt_token_sanity(const char *in, size_t in_len, + const char *iss, const char *aud, + const char *csrf_in, + char *sub, size_t sub_len, unsigned long *expiry_unix_time) +{ + unsigned long now = lws_now_secs(), exp; + const char *cp; + size_t len; + + /* + * It has our issuer? + */ + + if (lws_json_simple_strcmp(in, in_len, "\"iss\":", iss)) { + lwsl_notice("%s: iss mismatch\n", __func__); + return 1; + } + + /* + * ... it is indended for us to consume? (this is set + * to the public base url for this sai instance) + */ + if (lws_json_simple_strcmp(in, in_len, "\"aud\":", aud)) { + lwsl_notice("%s: aud mismatch\n", __func__); + return 1; + } + + /* + * ...it's not too early for it? + */ + cp = lws_json_simple_find(in, in_len, "\"nbf\":", &len); + if (!cp || (unsigned long)atol(cp) > now) { + lwsl_notice("%s: nbf fail\n", __func__); + return 1; + } + + /* + * ... and not too late for it? + */ + cp = lws_json_simple_find(in, in_len, "\"exp\":", &len); + exp = (unsigned long)atol(cp); + if (!cp || (unsigned long)atol(cp) < now) { + lwsl_notice("%s: exp fail %lu vs %lu\n", __func__, + cp ? (unsigned long)atol(cp) : 0, now); + return 1; + } + + /* + * Caller cares about subject? Then we must have it, and it can't be + * empty. + */ + + if (sub) { + cp = lws_json_simple_find(in, in_len, "\"sub\":", &len); + if (!cp || !len) { + lwsl_notice("%s: missing subject\n", __func__); + return 1; + } + lws_strnncpy(sub, cp, len, sub_len); + } + + /* + * If caller has been told a Cross Site Request Forgery (CSRF) nonce, + * require this JWT to express the same CSRF... this makes generated + * links for dangerous privileged auth'd actions expire with the JWT + * that was accessing the site when the links were generated. And it + * leaves an attacker not knowing what links to synthesize unless he + * can read the token or pages generated with it. + * + * Using this is very good for security, but it implies you must refresh + * generated pages still when the auth token is expiring (and the user + * must log in again). + */ + + if (csrf_in && + lws_json_simple_strcmp(in, in_len, "\"csrf\":", csrf_in)) { + lwsl_notice("%s: csrf mismatch\n", __func__); + return 1; + } + + if (expiry_unix_time) + *expiry_unix_time = exp; + + return 0; +} diff --git a/lib/roles/http/parsers.c b/lib/roles/http/parsers.c index 1382e97e4..5973b9005 100644 --- a/lib/roles/http/parsers.c +++ b/lib/roles/http/parsers.c @@ -1468,6 +1468,7 @@ lws_http_cookie_get(struct lws *wsi, const char *name, char *buf, char *p, *bo = buf; n = lws_hdr_total_length(wsi, WSI_TOKEN_HTTP_COOKIE); + lwsl_notice("%s: cookie hdr len %d\n", __func__, n); if (n < bl + 1) return 1; @@ -1475,6 +1476,8 @@ lws_http_cookie_get(struct lws *wsi, const char *name, char *buf, if (!p) return 1; + lwsl_hexdump_notice(p, n); + p += bl; n -= bl; while (n-- > bl) { @@ -1497,3 +1500,117 @@ lws_http_cookie_get(struct lws *wsi, const char *name, char *buf, return 1; } + +#if defined(LWS_WITH_JOSE) + +#define MAX_JWT_SIZE 1024 + +int +lws_jwt_get_http_cookie_validate_jwt(struct lws *wsi, + struct lws_jwt_sign_set_cookie *i, + char *out, size_t *out_len) +{ + char temp[MAX_JWT_SIZE * 2]; + size_t cml = *out_len; + const char *cp; + + /* first use out to hold the encoded JWT */ + + if (lws_http_cookie_get(wsi, i->cookie_name, out, out_len)) { + lwsl_notice("%s: cookie %s not provided\n", __func__, + i->cookie_name); + return 1; + } + + /* decode the JWT into temp */ + + if (lws_jwt_signed_validate(wsi->context, i->jwk, i->alg, out, + *out_len, temp, sizeof(temp), out, &cml)) { + lwsl_notice("%s: jwt validation failed\n", __func__); + return 1; + } + + /* + * Copy out the decoded JWT payload into out, overwriting the + * original encoded JWT taken from the cookie (that has long ago been + * translated into allocated buffers in the JOSE object) + */ + + if (lws_jwt_token_sanity(out, cml, i->iss, i->aud, i->csrf_in, + i->sub, sizeof(i->sub), + &i->expiry_unix_time)) { + lwsl_notice("%s: jwt sanity failed\n", __func__); + return 1; + } + + /* + * If he's interested in his private JSON part, point him to that in + * the args struct (it's pointing to the data in out + */ + + cp = lws_json_simple_find(out, cml, "\"ext\":", &i->extra_json_len); + if (cp) + i->extra_json = cp; + + if (cp) + lwsl_hexdump_notice(cp, i->extra_json_len); + else + lwsl_notice("%s: no ext JWT payload\n", __func__); + + return 0; +} + +int +lws_jwt_sign_token_set_http_cookie(struct lws *wsi, + const struct lws_jwt_sign_set_cookie *i, + uint8_t **p, uint8_t *end) +{ + char plain[MAX_JWT_SIZE + 1], temp[MAX_JWT_SIZE * 2], csrf[17]; + size_t pl = sizeof(plain); + unsigned long long ull; + int n; + + /* + * Create a 16-char random csrf token with the same lifetime as the JWT + */ + + lws_hex_random(wsi->context, csrf, sizeof(csrf)); + ull = lws_now_secs(); + if (lws_jwt_sign_compact(wsi->context, i->jwk, i->alg, plain, &pl, + temp, sizeof(temp), + "{\"iss\":\"%s\",\"aud\":\"%s\"," + "\"iat\":%llu,\"nbf\":%llu,\"exp\":%llu," + "\"csrf\":\"%s\",\"sub\":\"%s\"%s%s%s}", + i->iss, i->aud, ull, ull - 60, + ull + i->expiry_unix_time, + csrf, i->sub, + i->extra_json ? ",\"ext\":{" : "", + i->extra_json ? i->extra_json : "", + i->extra_json ? "}" : "")) { + lwsl_err("%s: failed to create JWT\n", __func__); + + return 1; + } + + /* + * There's no point the browser holding on to a JWT beyond the JWT's + * expiry time, so set it to be the same. + */ + + n = lws_snprintf(temp, sizeof(temp), "__Host-%s=%s;" + "HttpOnly;" + "Secure;" + "SameSite=strict;" + "Path=/;" + "Max-Age=%lu", + i->cookie_name, plain, i->expiry_unix_time); + + if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_SET_COOKIE, + (uint8_t *)temp, n, p, end)) { + lwsl_err("%s: failed to add JWT cookie header\n", __func__); + return 1; + } + + return 0; +} +#endif